@elitedcs/ghl-mcp 3.44.0 → 3.45.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js
CHANGED
|
@@ -31,7 +31,7 @@ var require_package = __commonJS({
|
|
|
31
31
|
"package.json"(exports2, module2) {
|
|
32
32
|
module2.exports = {
|
|
33
33
|
name: "@elitedcs/ghl-mcp",
|
|
34
|
-
version: "3.
|
|
34
|
+
version: "3.45.0",
|
|
35
35
|
mcpName: "io.github.drjerryrelth/ghl-command",
|
|
36
36
|
description: "GoHighLevel MCP Server for Claude. 218 tools \u2014 full CRM, automation, marketing control, account-wide workflow audit, and the only programmatic GHL workflow builder, now multi-tenant across client accounts.",
|
|
37
37
|
main: "dist/index.js",
|
|
@@ -43,6 +43,8 @@ var require_package = __commonJS({
|
|
|
43
43
|
"templates/action-schemas.json",
|
|
44
44
|
"templates/clinic-medspa.json",
|
|
45
45
|
"templates/trigger-schemas.json",
|
|
46
|
+
"templates/external-funnel/cloudflare-worker.js",
|
|
47
|
+
"templates/external-funnel/README.md",
|
|
46
48
|
"README.md",
|
|
47
49
|
"CHANGELOG.md"
|
|
48
50
|
],
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@elitedcs/ghl-mcp",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.45.0",
|
|
4
4
|
"mcpName": "io.github.drjerryrelth/ghl-command",
|
|
5
5
|
"description": "GoHighLevel MCP Server for Claude. 218 tools — full CRM, automation, marketing control, account-wide workflow audit, and the only programmatic GHL workflow builder, now multi-tenant across client accounts.",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -12,6 +12,8 @@
|
|
|
12
12
|
"templates/action-schemas.json",
|
|
13
13
|
"templates/clinic-medspa.json",
|
|
14
14
|
"templates/trigger-schemas.json",
|
|
15
|
+
"templates/external-funnel/cloudflare-worker.js",
|
|
16
|
+
"templates/external-funnel/README.md",
|
|
15
17
|
"README.md",
|
|
16
18
|
"CHANGELOG.md"
|
|
17
19
|
],
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# External-Funnel Lead Bridge (power-user, self-hosted)
|
|
2
|
+
|
|
3
|
+
When a Blueprint funnel is `target: "external"`, GHL Command does **not** build or
|
|
4
|
+
host the site — you do, on your own Cloudflare/Vercel account, and wire its form
|
|
5
|
+
back to your GHL sub-account with this lead bridge. **GHL Command never holds your
|
|
6
|
+
keys, deploys for you, or touches your accounts.** This is a technically-capable,
|
|
7
|
+
self-serve path — not a novice one.
|
|
8
|
+
|
|
9
|
+
## What you need (capability check — be honest before you start)
|
|
10
|
+
- Your own **Cloudflare** (or Vercel) account + the CLI (`wrangler` / `vercel`).
|
|
11
|
+
- A **GHL Private Integration token** (Settings → Private Integrations) scoped to
|
|
12
|
+
`contacts.write` for the target sub-account. You'll store it as a host secret.
|
|
13
|
+
- Comfort with a one-time command-line deploy and editing a config file.
|
|
14
|
+
- (Optional) a custom **domain** + DNS access to point it at the site.
|
|
15
|
+
|
|
16
|
+
If any of that isn't you, choose `target: "ghl"` instead — Blueprint builds the
|
|
17
|
+
funnel inside GHL with no setup on your part.
|
|
18
|
+
|
|
19
|
+
## The pieces
|
|
20
|
+
1. **Your site** — generated by the Blueprint skill (frontend-design) or your own;
|
|
21
|
+
hosted on Cloudflare Pages / Vercel. Its form POSTs JSON to the bridge.
|
|
22
|
+
2. **The lead bridge** — `cloudflare-worker.js` here (Vercel variant is analogous:
|
|
23
|
+
same logic in an `api/lead.js` handler; read config from `process.env`).
|
|
24
|
+
3. **Your GHL sub-account** — already built by `apply_build_plan`: the custom
|
|
25
|
+
fields, the trigger tag, and the speed-to-lead workflow. The bridge feeds it.
|
|
26
|
+
4. **Booking** — link your site's "book" button straight to the GHL calendar's
|
|
27
|
+
public URL (no bridge needed).
|
|
28
|
+
|
|
29
|
+
## Wiring values (from `apply_build_plan`)
|
|
30
|
+
The executor returns what you plug into the Worker config + form:
|
|
31
|
+
- `GHL_LOCATION_ID` — the sub-account id.
|
|
32
|
+
- `TRIGGER_TAG` — the tag that starts your workflow.
|
|
33
|
+
- the custom **field IDs** your form's `custom` object should send (`{ "<fieldId>": value }` — GHL's upsert keys custom fields by id, not name).
|
|
34
|
+
- the calendar **booking URL** for the site's CTA.
|
|
35
|
+
|
|
36
|
+
## Deploy (Cloudflare)
|
|
37
|
+
1. `npm i -g wrangler && wrangler login`
|
|
38
|
+
2. `wrangler secret put GHL_PIT` ← paste your token (secret; never in code/repo)
|
|
39
|
+
3. Set `[vars]` in `wrangler.toml`: `GHL_LOCATION_ID`, `TRIGGER_TAG`,
|
|
40
|
+
`ALLOWED_ORIGIN` (your funnel domain — locks CORS), optional `RE_ENROLL`.
|
|
41
|
+
4. (Optional) Turnstile: add the widget to your form, `wrangler secret put
|
|
42
|
+
TURNSTILE_SECRET`.
|
|
43
|
+
5. `wrangler deploy` → point your form at the Worker URL.
|
|
44
|
+
|
|
45
|
+
## Safety built in
|
|
46
|
+
- **Secret stays server-side** — the token lives only as a Cloudflare secret, never
|
|
47
|
+
in the browser.
|
|
48
|
+
- **Spam gate** — honeypot field (`_hp`) + Cloudflare Turnstile. **Strongly enable
|
|
49
|
+
Turnstile in production:** this is a public, unauthenticated endpoint, and the
|
|
50
|
+
honeypot only filters naive browser bots — without Turnstile, any scripted client
|
|
51
|
+
can post directly. Turnstile is the real abuse gate.
|
|
52
|
+
- **Dedup** — uses `/contacts/upsert` (by email/phone), so repeat submits update,
|
|
53
|
+
never duplicate.
|
|
54
|
+
- **Fail-closed** — if GHL can't be reached, the form gets an error (so the lead is
|
|
55
|
+
retried), not a silent success that drops it. Logs to `wrangler tail`.
|
|
56
|
+
- **Origin check** — `ALLOWED_ORIGIN` limits browser cross-origin access and is
|
|
57
|
+
required (no wildcard). NOTE: this is **not** authentication — it does not block
|
|
58
|
+
`curl` or server-side clients (they send no/forged Origin). Use Turnstile for that.
|
|
59
|
+
|
|
60
|
+
## Re-enrollment caveat
|
|
61
|
+
GHL's Contact-Tag trigger fires on a tag **transition**, so re-adding a tag a
|
|
62
|
+
contact already has won't re-fire the workflow. For speed-to-lead that's usually
|
|
63
|
+
what you want. If you need returning submitters re-enrolled, set `RE_ENROLL=true`
|
|
64
|
+
(the Worker removes + re-adds the tag) AND enable re-enrollment on the workflow.
|
|
65
|
+
Confirm the behavior on your account before relying on it.
|
|
66
|
+
|
|
67
|
+
## Compliance
|
|
68
|
+
If your workflow sends SMS/email, your external form MUST collect explicit consent
|
|
69
|
+
(a checkbox + clear copy). Define a **consent custom field** in your Blueprint plan
|
|
70
|
+
and send its value in `custom` (keyed by that field's id) like any other field, so
|
|
71
|
+
the consent is recorded on the contact. You own CAN-SPAM/TCPA compliance for an
|
|
72
|
+
externally-captured lead.
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GHL Command — External-Funnel Lead Bridge (Cloudflare Worker)
|
|
3
|
+
* ============================================================================
|
|
4
|
+
* Receives a POST from YOUR externally-hosted funnel/site form and pushes the
|
|
5
|
+
* lead into YOUR GoHighLevel sub-account: it upserts the contact (dedup by
|
|
6
|
+
* email/phone) and adds the trigger tag that fires your speed-to-lead workflow
|
|
7
|
+
* (the one GHL Command Blueprint built for you).
|
|
8
|
+
*
|
|
9
|
+
* This is the "wired to GHL via webhooks" piece. It runs in YOUR Cloudflare
|
|
10
|
+
* account with YOUR GHL token. GHL Command never sees it, deploys it, or holds
|
|
11
|
+
* your keys — this is a power-user, self-hosted path.
|
|
12
|
+
*
|
|
13
|
+
* ─── SETUP (one time) ───────────────────────────────────────────────────────
|
|
14
|
+
* 1. Install Wrangler: npm i -g wrangler (then `wrangler login`)
|
|
15
|
+
* 2. Put your GHL token as a SECRET (never in code/vars):
|
|
16
|
+
* wrangler secret put GHL_PIT
|
|
17
|
+
* (Use a GHL Private Integration token scoped to contacts.write for the
|
|
18
|
+
* target sub-account. Treat it like a password — if it leaks, rotate it in
|
|
19
|
+
* GHL > Settings > Private Integrations.)
|
|
20
|
+
* 3. Set the non-secret config in wrangler.toml [vars]:
|
|
21
|
+
* GHL_LOCATION_ID = "your sub-account id" (from get_current_location)
|
|
22
|
+
* TRIGGER_TAG = "the tag that starts your workflow" (e.g. "new-lead")
|
|
23
|
+
* ALLOWED_ORIGIN = "https://your-funnel-domain.com" (CORS lock)
|
|
24
|
+
* TURNSTILE_SECRET (optional) — set as a SECRET if you enable Turnstile
|
|
25
|
+
* RE_ENROLL = "false" (see "Re-enrollment" below)
|
|
26
|
+
* 4. Deploy: wrangler deploy
|
|
27
|
+
* 5. Point your form's submit at the Worker URL (POST JSON).
|
|
28
|
+
*
|
|
29
|
+
* ─── FORM CONTRACT ──────────────────────────────────────────────────────────
|
|
30
|
+
* POST application/json. Standard keys map to GHL standard fields; `custom` is an
|
|
31
|
+
* object keyed by GHL custom-field ID (from apply_build_plan's wiring bundle) →
|
|
32
|
+
* value. Consent is just a custom field (include one in your plan if you trigger
|
|
33
|
+
* SMS/email — see the README compliance note).
|
|
34
|
+
* { "first_name","last_name","email","phone",
|
|
35
|
+
* "custom": { "<ghlCustomFieldId>":"Botox", "<consentFieldId>":"yes", ... },
|
|
36
|
+
* "_hp": "" } // honeypot — bots fill it, humans don't
|
|
37
|
+
*
|
|
38
|
+
* ─── Re-enrollment caveat (READ) ────────────────────────────────────────────
|
|
39
|
+
* GHL's "Contact Tag" trigger fires on a tag TRANSITION (not-present → present).
|
|
40
|
+
* Re-adding a tag a contact ALREADY has does NOT re-fire the workflow. For
|
|
41
|
+
* speed-to-lead that's usually correct (don't re-spam someone already in your
|
|
42
|
+
* pipeline). If you DO need returning submitters re-enrolled, set RE_ENROLL=true
|
|
43
|
+
* (this Worker removes then re-adds the tag to force a transition) AND enable
|
|
44
|
+
* re-enrollment on the workflow in GHL. [Confirm the exact behavior on your
|
|
45
|
+
* account before relying on it.]
|
|
46
|
+
* ============================================================================
|
|
47
|
+
*/
|
|
48
|
+
|
|
49
|
+
const GHL_BASE = "https://services.leadconnectorhq.com";
|
|
50
|
+
const GHL_VERSION = "2021-07-28";
|
|
51
|
+
|
|
52
|
+
export default {
|
|
53
|
+
async fetch(request, env) {
|
|
54
|
+
// Server must be configured with the funnel's origin.
|
|
55
|
+
if (!env.ALLOWED_ORIGIN) return json({ error: "server misconfigured: set ALLOWED_ORIGIN" }, 500, {});
|
|
56
|
+
// Browser cross-origin lock: block a mismatched Origin. NOTE: this is NOT a
|
|
57
|
+
// security boundary — a non-browser client (curl/server script) sends no/forged
|
|
58
|
+
// Origin and bypasses CORS entirely. Real abuse protection = Turnstile (below).
|
|
59
|
+
const origin = request.headers.get("Origin");
|
|
60
|
+
if (origin && origin !== env.ALLOWED_ORIGIN) return json({ error: "forbidden origin" }, 403, {});
|
|
61
|
+
const cors = corsHeaders(env);
|
|
62
|
+
if (request.method === "OPTIONS") return new Response(null, { status: 204, headers: cors });
|
|
63
|
+
if (request.method !== "POST") return json({ error: "POST only" }, 405, cors);
|
|
64
|
+
|
|
65
|
+
let data;
|
|
66
|
+
try { data = await request.json(); } catch { return json({ error: "invalid JSON" }, 400, cors); }
|
|
67
|
+
|
|
68
|
+
// 1. Spam gate — honeypot first (free), then Turnstile if configured.
|
|
69
|
+
if (typeof data._hp === "string" && data._hp.trim() !== "") {
|
|
70
|
+
return json({ ok: true }, 200, cors); // silently accept + drop the bot
|
|
71
|
+
}
|
|
72
|
+
if (env.TURNSTILE_SECRET) {
|
|
73
|
+
const ok = await verifyTurnstile(data.cf_turnstile_token, env.TURNSTILE_SECRET, request);
|
|
74
|
+
if (!ok) return json({ error: "captcha failed" }, 403, cors);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// 2. Minimal validation — need at least an email or phone to dedup on.
|
|
78
|
+
const email = clean(data.email);
|
|
79
|
+
const phone = clean(data.phone);
|
|
80
|
+
if (!email && !phone) return json({ error: "email or phone required" }, 422, cors);
|
|
81
|
+
|
|
82
|
+
// 3. Build the GHL upsert body. customFields are { id, value } where id is the
|
|
83
|
+
// GHL custom-field ID (from apply_build_plan's wiring bundle) — this is the
|
|
84
|
+
// shape GHL's v2 /contacts/upsert + this product's upsert_contact use. The
|
|
85
|
+
// form's `custom` object is keyed by those ids. (Consent is just another
|
|
86
|
+
// custom field — include a consent field in your plan and send it here.)
|
|
87
|
+
const customFields = Object.entries(data.custom || {})
|
|
88
|
+
.filter(([, v]) => v !== undefined && v !== null && v !== "")
|
|
89
|
+
.map(([id, value]) => ({ id, value }));
|
|
90
|
+
|
|
91
|
+
const body = {
|
|
92
|
+
locationId: env.GHL_LOCATION_ID,
|
|
93
|
+
...(email ? { email } : {}),
|
|
94
|
+
...(phone ? { phone } : {}),
|
|
95
|
+
...(clean(data.first_name) ? { firstName: clean(data.first_name) } : {}),
|
|
96
|
+
...(clean(data.last_name) ? { lastName: clean(data.last_name) } : {}),
|
|
97
|
+
tags: [env.TRIGGER_TAG],
|
|
98
|
+
customFields,
|
|
99
|
+
source: "external-funnel",
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
// 4. Upsert with bounded retry/backoff (429/5xx/network). Fail-CLOSED: if the
|
|
103
|
+
// lead can't be written, tell the user so it can be retried — never a
|
|
104
|
+
// silent green that drops a paying lead.
|
|
105
|
+
try {
|
|
106
|
+
const upserted = await ghlFetch(`${GHL_BASE}/contacts/upsert`, "POST", body, env.GHL_PIT);
|
|
107
|
+
const contactId = upserted?.contact?.id || upserted?.id;
|
|
108
|
+
// Optional: force re-enrollment for a returning contact (tag transition).
|
|
109
|
+
// The remove is best-effort (the tag may not be present); the re-ADD MUST
|
|
110
|
+
// succeed (it's what re-fires the workflow) — let its failure propagate so
|
|
111
|
+
// we don't report success when re-enrollment silently failed.
|
|
112
|
+
if (env.RE_ENROLL === "true" && contactId) {
|
|
113
|
+
await ghlFetch(`${GHL_BASE}/contacts/${contactId}/tags`, "DELETE", { tags: [env.TRIGGER_TAG] }, env.GHL_PIT).catch(() => {});
|
|
114
|
+
await ghlFetch(`${GHL_BASE}/contacts/${contactId}/tags`, "POST", { tags: [env.TRIGGER_TAG] }, env.GHL_PIT);
|
|
115
|
+
}
|
|
116
|
+
return json({ ok: true, contactId }, 200, cors);
|
|
117
|
+
} catch (err) {
|
|
118
|
+
console.error("lead-bridge upsert failed", String(err)); // shows in `wrangler tail`
|
|
119
|
+
return json({ error: "lead delivery failed — please try again" }, 502, cors);
|
|
120
|
+
}
|
|
121
|
+
},
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
// ── helpers ─────────────────────────────────────────────────────────────────
|
|
125
|
+
function corsHeaders(env) {
|
|
126
|
+
return {
|
|
127
|
+
"Access-Control-Allow-Origin": env.ALLOWED_ORIGIN, // required + validated above; no "*" fallback
|
|
128
|
+
"Access-Control-Allow-Methods": "POST, OPTIONS",
|
|
129
|
+
"Access-Control-Allow-Headers": "Content-Type",
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
function json(obj, status, cors) {
|
|
133
|
+
return new Response(JSON.stringify(obj), { status, headers: { "Content-Type": "application/json", ...(cors || {}) } });
|
|
134
|
+
}
|
|
135
|
+
function clean(v) { return typeof v === "string" ? v.trim() : ""; }
|
|
136
|
+
|
|
137
|
+
async function ghlFetch(url, method, body, pit, attempt = 1) {
|
|
138
|
+
const res = await fetch(url, {
|
|
139
|
+
method,
|
|
140
|
+
headers: {
|
|
141
|
+
Authorization: `Bearer ${pit}`,
|
|
142
|
+
Version: GHL_VERSION,
|
|
143
|
+
"Content-Type": "application/json",
|
|
144
|
+
Accept: "application/json",
|
|
145
|
+
},
|
|
146
|
+
body: method === "GET" ? undefined : JSON.stringify(body),
|
|
147
|
+
});
|
|
148
|
+
if (res.status === 429 || res.status >= 500) {
|
|
149
|
+
if (attempt < 4) {
|
|
150
|
+
await sleep(250 * attempt);
|
|
151
|
+
return ghlFetch(url, method, body, pit, attempt + 1);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
const text = await res.text();
|
|
155
|
+
if (!res.ok) throw new Error(`GHL ${res.status}: ${text.slice(0, 200)}`);
|
|
156
|
+
try { return JSON.parse(text); } catch { return {}; }
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
async function verifyTurnstile(token, secret, request) {
|
|
160
|
+
if (!token) return false;
|
|
161
|
+
const form = new FormData();
|
|
162
|
+
form.append("secret", secret);
|
|
163
|
+
form.append("response", token);
|
|
164
|
+
const ip = request.headers.get("CF-Connecting-IP");
|
|
165
|
+
if (ip) form.append("remoteip", ip);
|
|
166
|
+
const r = await fetch("https://challenges.cloudflare.com/turnstile/v0/siteverify", { method: "POST", body: form });
|
|
167
|
+
const out = await r.json().catch(() => ({ success: false }));
|
|
168
|
+
return out.success === true;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|