@elitedcs/ghl-mcp 3.33.0 → 3.34.1

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/CHANGELOG.md CHANGED
@@ -1,5 +1,48 @@
1
1
  # Changelog
2
2
 
3
+ ## 3.34.1 — CLI list-locations shows companyId
4
+
5
+ `ghl-mcp cli list-locations` now includes each location's `companyId` (same as
6
+ the `list_registered_locations` tool) — the quick way to tell whether your
7
+ locations live under one agency company or several. Different companyIds =
8
+ separate agency accounts, each needing its own `firebaseByCompany` entry for
9
+ the workflow builder.
10
+
11
+ ## 3.34.0 — Headless/server deployment hardening
12
+
13
+ Built for production server installs (Linux droplets, containers, MCP
14
+ gateways). License scope, stated plainly: **one license covers every
15
+ GoHighLevel sub-account you manage — unlimited, across multiple agency
16
+ companies — on up to 3 of your own machines. Never metered per account.**
17
+ Nothing in the code ever capped registered locations; the docs now say so.
18
+
19
+ - **Headless seed CLI** behind an explicit sentinel (`ghl-mcp cli …`), so
20
+ gateway-supplied argv can never trigger a subcommand:
21
+ `register-location`, `register-company-firebase`, `register-agency-key`,
22
+ `list-locations`. Live validation identical to the interactive tools
23
+ (`--no-validate` for air-gapped builds), strict arg parsing, deterministic
24
+ exit codes (0/2/3/4), config-dir writability preflight, never prints keys.
25
+ - **`.ghl-tokens.json` schema documented** with a pre-populatable example —
26
+ the registry is read at boot, so a hand-seeded file just works. Full guide
27
+ at `docs/HEADLESS.md` (+ new README "Server / Headless Deployment" section).
28
+ - **Firebase scope documented:** credentials are per-COMPANY, not
29
+ per-sub-account — one set covers every sub-account under a company; each
30
+ company's token rotates and persists independently.
31
+ - **`GHL_MCP_CONFIG_DIR`** — explicit absolute-path config-dir override so all
32
+ state (including auto-rotated Firebase tokens) lives on a persistent
33
+ mounted volume. Relative paths fail fast at boot.
34
+ - **Node 20 supported** (`engines: >=20`, esbuild target lowered to node20).
35
+ Verified on real Node 20: full test suite, server boot, CLI, and Ed25519
36
+ attestation verification (one harmless ExperimentalWarning). The publish
37
+ smoke test now runs on a Node 20/22/24 matrix.
38
+ - **Graceful shutdown** on SIGTERM/SIGINT (bounded 3s) for supervised
39
+ deployments.
40
+ - **`GHL_MCP_DISABLE_UPDATE_CHECK=1`** disables the startup npm version ping
41
+ (air-gapped/firewalled servers).
42
+ - Transport remains stdio by design (gateways supervise it as a child
43
+ process); an optional Streamable-HTTP mode is on the roadmap for the
44
+ server-deployment track.
45
+
3
46
  ## 3.33.0 — Account-wide silent-failure audit + trust hardening
4
47
 
5
48
  **`audit_workflows`** — scans EVERY workflow in the current location for
package/README.md CHANGED
@@ -26,7 +26,7 @@ Built by [Elite DCs, LLC](https://elitedcs.com).
26
26
  > "Thank you, Jerry. You're a star."
27
27
  > — Frankie B.
28
28
 
29
- 27 releases on npm since launch (v3.6.0 → v3.27.0). Bugs get fixed in days, not quarters.
29
+ 30+ releases on npm since launch (v3.6.0 → v3.34.0). Bugs get fixed in days, not quarters.
30
30
 
31
31
  ---
32
32
 
@@ -217,7 +217,25 @@ To unlock full builder access across multiple clients from one install:
217
217
 
218
218
  ---
219
219
 
220
- ## Tools (190)
220
+ ## Server / Headless Deployment
221
+
222
+ GHL Command runs headless — Linux droplet, container, or behind an MCP gateway (MetaMCP, etc.). **One license covers every GoHighLevel sub-account you manage, on up to 3 of your own machines. No per-account fees.** A server counts as one machine; restarts never consume extra activations.
223
+
224
+ Highlights (full guide: [`docs/HEADLESS.md`](docs/HEADLESS.md)):
225
+
226
+ - **Scriptable multi-account setup** — pre-seed the documented `.ghl-tokens.json`, or use the CLI in a Dockerfile/entrypoint:
227
+ ```bash
228
+ npx -y @elitedcs/ghl-mcp cli register-location --location-id XXXX --api-key pit-... --name "Account"
229
+ npx -y @elitedcs/ghl-mcp cli register-company-firebase --company-id YYYY --refresh-token ... --user-id ...
230
+ npx -y @elitedcs/ghl-mcp cli register-agency-key --api-key pit-...
231
+ ```
232
+ - **Persistent config dir** — set `GHL_MCP_CONFIG_DIR=/data/ghl-mcp` (absolute path) on a mounted volume so auto-rotated Firebase tokens survive container restarts.
233
+ - **Firebase is per-company, not per-sub-account** — one credential set covers all sub-accounts under a company.
234
+ - **Node 20+**, graceful SIGTERM shutdown, `GHL_MCP_DISABLE_UPDATE_CHECK=1` for air-gapped boxes. Transport is stdio (your gateway supervises it as a child process); an HTTP serve mode is on the roadmap.
235
+
236
+ ---
237
+
238
+ ## Tools
221
239
 
222
240
  > **v3.10.0 adds Email Templates** (`list_email_templates`, `create_email_template`, `update_email_template`) — Claude can now create new HTML email templates and save content into them via the public API. Templates power both standalone marketing emails and workflow email actions. Delete + rename remain UI-only (no public-API endpoint exists).
223
241
 
@@ -648,9 +666,9 @@ This MCP server is safe to share via GitHub.
648
666
  | Concern | How It's Handled |
649
667
  |---|---|
650
668
  | **API Keys** | Stored in a per-user credentials file (`~/Library/Application Support/elitedcs-ghl-mcp/credentials.json` on Mac) at chmod 0600. Never in code, never committed. |
651
- | **License validation** | One-time check at `setup_ghl_mcp` against elitedcs.com license server. After activation, no further phone-home. |
669
+ | **License validation** | Checked at `setup_ghl_mcp` against the elitedcs.com license server, then cached as a signed attestation (~30 days). The server renews the attestation in the background as expiry approaches — never per-request, and your GHL keys are never sent to us. |
652
670
  | **Multi-user** | Each user brings their own license key + GHL API key. Complete account isolation. |
653
- | **Scope** | The server only talks to GHL's API and (once, at setup) the elitedcs.com license server. No filesystem access beyond the credentials file, no shell commands. |
671
+ | **Scope** | The server only talks to GHL's API, the elitedcs.com license server (setup + periodic attestation renewal), and npm's registry (startup version banner — `GHL_MCP_DISABLE_UPDATE_CHECK=1` to disable). No filesystem access beyond its config dir, no shell commands. |
654
672
  | **Permissions** | Controlled by your GHL Private Integration scopes. Disable what you don't need. |
655
673
 
656
674
  ---
@@ -778,7 +796,7 @@ Built by **[Elite DCs, LLC](https://elitedcs.com)** — a digital marketing and
778
796
 
779
797
  **Tech stack:** TypeScript, Node.js, esbuild, MCP SDK, Zod, GHL API v2, Firebase Auth
780
798
 
781
- **Version:** 2.3.0
799
+ **Version:** 3.34.0
782
800
 
783
801
  ---
784
802
 
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.33.0",
34
+ version: "3.34.1",
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",
@@ -47,7 +47,7 @@ var require_package = __commonJS({
47
47
  "CHANGELOG.md"
48
48
  ],
49
49
  scripts: {
50
- build: "esbuild src/index.ts --bundle --platform=node --target=node22 --format=cjs --outfile=dist/index.js --packages=external",
50
+ build: "esbuild src/index.ts --bundle --platform=node --target=node20 --format=cjs --outfile=dist/index.js --packages=external",
51
51
  setup: "node setup-wizard.mjs",
52
52
  start: "node dist/index.js",
53
53
  dev: "tsc --watch",
@@ -85,7 +85,7 @@ var require_package = __commonJS({
85
85
  access: "public"
86
86
  },
87
87
  engines: {
88
- node: ">=22"
88
+ node: ">=20"
89
89
  },
90
90
  dependencies: {
91
91
  "@modelcontextprotocol/sdk": "^1.12.1",
@@ -106,8 +106,8 @@ var require_package = __commonJS({
106
106
 
107
107
  // src/index.ts
108
108
  var dotenv2 = __toESM(require("dotenv"));
109
- var path5 = __toESM(require("path"));
110
- var fs5 = __toESM(require("fs"));
109
+ var path6 = __toESM(require("path"));
110
+ var fs6 = __toESM(require("fs"));
111
111
  var import_mcp = require("@modelcontextprotocol/sdk/server/mcp.js");
112
112
  var import_stdio = require("@modelcontextprotocol/sdk/server/stdio.js");
113
113
 
@@ -171,8 +171,8 @@ var GHLClient = class {
171
171
  Version: version || GHL_API_VERSION
172
172
  };
173
173
  }
174
- buildUrl(path6, params) {
175
- const url = new URL(path6, GHL_BASE_URL);
174
+ buildUrl(path7, params) {
175
+ const url = new URL(path7, GHL_BASE_URL);
176
176
  if (params) {
177
177
  for (const [key, value] of Object.entries(params)) {
178
178
  if (value !== void 0 && value !== null) {
@@ -182,8 +182,8 @@ var GHLClient = class {
182
182
  }
183
183
  return url.toString();
184
184
  }
185
- async request(method, path6, options = {}, attempt = 0) {
186
- const url = this.buildUrl(path6, options.params);
185
+ async request(method, path7, options = {}, attempt = 0) {
186
+ const url = this.buildUrl(path7, options.params);
187
187
  const headers = this.buildHeaders(options.version);
188
188
  const fetchOptions = {
189
189
  method,
@@ -201,14 +201,14 @@ var GHLClient = class {
201
201
  } catch (error) {
202
202
  clearTimeout(timeout);
203
203
  if (error instanceof Error && error.name === "AbortError") {
204
- throw new Error(`Request timeout (30s): ${method} ${path6}`);
204
+ throw new Error(`Request timeout (30s): ${method} ${path7}`);
205
205
  }
206
206
  if (!options.noRetry && attempt < MAX_RETRIES) {
207
207
  const delay4 = computeRetryDelay(null, attempt, BASE_DELAY_MS);
208
- process.stderr.write(`[ghl-mcp] Network error on ${method} ${path6}, retry ${attempt + 1}/${MAX_RETRIES} in ${delay4}ms
208
+ process.stderr.write(`[ghl-mcp] Network error on ${method} ${path7}, retry ${attempt + 1}/${MAX_RETRIES} in ${delay4}ms
209
209
  `);
210
210
  await new Promise((r) => setTimeout(r, delay4));
211
- return this.request(method, path6, options, attempt + 1);
211
+ return this.request(method, path7, options, attempt + 1);
212
212
  }
213
213
  throw error;
214
214
  } finally {
@@ -216,10 +216,10 @@ var GHLClient = class {
216
216
  }
217
217
  if (!options.noRetry && (response.status === 429 || response.status >= 500) && attempt < MAX_RETRIES) {
218
218
  const delay4 = computeRetryDelay(response.headers.get("Retry-After"), attempt, BASE_DELAY_MS);
219
- process.stderr.write(`[ghl-mcp] ${response.status} on ${method} ${path6}, retry ${attempt + 1}/${MAX_RETRIES} in ${delay4}ms
219
+ process.stderr.write(`[ghl-mcp] ${response.status} on ${method} ${path7}, retry ${attempt + 1}/${MAX_RETRIES} in ${delay4}ms
220
220
  `);
221
221
  await new Promise((r) => setTimeout(r, delay4));
222
- return this.request(method, path6, options, attempt + 1);
222
+ return this.request(method, path7, options, attempt + 1);
223
223
  }
224
224
  if (!response.ok) {
225
225
  let errorBody = "";
@@ -228,7 +228,7 @@ var GHLClient = class {
228
228
  } catch {
229
229
  }
230
230
  throw new Error(
231
- `GHL API Error ${response.status} ${response.statusText}: ${method} ${path6}
231
+ `GHL API Error ${response.status} ${response.statusText}: ${method} ${path7}
232
232
  ${errorBody}`
233
233
  );
234
234
  }
@@ -240,20 +240,20 @@ ${errorBody}`
240
240
  return { message: text };
241
241
  }
242
242
  }
243
- async get(path6, options) {
244
- return this.request("GET", path6, options);
243
+ async get(path7, options) {
244
+ return this.request("GET", path7, options);
245
245
  }
246
- async post(path6, options) {
247
- return this.request("POST", path6, options);
246
+ async post(path7, options) {
247
+ return this.request("POST", path7, options);
248
248
  }
249
- async put(path6, options) {
250
- return this.request("PUT", path6, options);
249
+ async put(path7, options) {
250
+ return this.request("PUT", path7, options);
251
251
  }
252
- async patch(path6, options) {
253
- return this.request("PATCH", path6, options);
252
+ async patch(path7, options) {
253
+ return this.request("PATCH", path7, options);
254
254
  }
255
- async delete(path6, options) {
256
- return this.request("DELETE", path6, options);
255
+ async delete(path7, options) {
256
+ return this.request("DELETE", path7, options);
257
257
  }
258
258
  /**
259
259
  * Helper: resolves locationId from args or falls back to default
@@ -300,6 +300,15 @@ var CredentialsSchema = import_zod.z.object({
300
300
  signed_attestation: import_zod.z.string().optional()
301
301
  });
302
302
  function appDataDir() {
303
+ const override = process.env.GHL_MCP_CONFIG_DIR?.trim();
304
+ if (override) {
305
+ if (!path.isAbsolute(override)) {
306
+ throw new Error(
307
+ `GHL_MCP_CONFIG_DIR must be an absolute path (got "${override}"). Use e.g. /data/ghl-mcp in a container, with a volume mounted at /data.`
308
+ );
309
+ }
310
+ return override;
311
+ }
303
312
  const home = os.homedir();
304
313
  if (process.platform === "darwin") {
305
314
  return path.join(home, "Library", "Application Support", APP_NAME);
@@ -407,6 +416,7 @@ var TokenRegistryDataSchema = import_zod2.z.object({
407
416
  var TokenRegistry = class _TokenRegistry {
408
417
  data;
409
418
  loadFailure = null;
419
+ lastSaveError = null;
410
420
  filePath;
411
421
  constructor(filePath) {
412
422
  if (filePath) {
@@ -493,15 +503,25 @@ var TokenRegistry = class _TokenRegistry {
493
503
  fs2.chmodSync(this.filePath, 384);
494
504
  } catch {
495
505
  }
506
+ this.lastSaveError = null;
496
507
  } catch (error) {
497
508
  try {
498
509
  fs2.unlinkSync(tmpPath);
499
510
  } catch {
500
511
  }
512
+ this.lastSaveError = error instanceof Error ? error.message : String(error);
501
513
  process.stderr.write(`[ghl-mcp] Warning: Could not save token registry: ${error}
502
514
  `);
503
515
  }
504
516
  }
517
+ /**
518
+ * Non-null when the MOST RECENT save() failed (cleared by a later success).
519
+ * The seed CLI checks this after every write to turn the server's
520
+ * warn-and-continue policy into a hard exit code.
521
+ */
522
+ getLastSaveError() {
523
+ return this.lastSaveError;
524
+ }
505
525
  /**
506
526
  * Get the API key for a specific location
507
527
  */
@@ -4368,16 +4388,16 @@ function registerEmailTools(server2, client) {
4368
4388
  function registerEmailBuilderInternalTools(server2, builderClient) {
4369
4389
  const client = builderClient;
4370
4390
  if (!client) return;
4371
- async function builderRequest(method, path6, body) {
4391
+ async function builderRequest(method, path7, body) {
4372
4392
  const headers = await client.buildHeaders();
4373
- const response = await fetch(`${EMAIL_BUILDER_BASE}${path6}`, {
4393
+ const response = await fetch(`${EMAIL_BUILDER_BASE}${path7}`, {
4374
4394
  method,
4375
4395
  headers,
4376
4396
  body: body ? JSON.stringify(body) : void 0
4377
4397
  });
4378
4398
  if (!response.ok) {
4379
4399
  const text2 = await response.text();
4380
- throw new Error(`Email Builder API Error ${response.status}: ${method} /emails/builder${path6}
4400
+ throw new Error(`Email Builder API Error ${response.status}: ${method} /emails/builder${path7}
4381
4401
  ${text2}`);
4382
4402
  }
4383
4403
  const text = await response.text();
@@ -5634,23 +5654,23 @@ var import_zod34 = require("zod");
5634
5654
  function registerFunnelBuilderTools(server2, builderClient) {
5635
5655
  const client = builderClient;
5636
5656
  if (!client) return;
5637
- async function internalGet(path6) {
5638
- return client.request("GET", path6);
5657
+ async function internalGet(path7) {
5658
+ return client.request("GET", path7);
5639
5659
  }
5640
- async function internalPost(path6, body) {
5641
- return client.request("POST", path6, body);
5660
+ async function internalPost(path7, body) {
5661
+ return client.request("POST", path7, body);
5642
5662
  }
5643
- async function internalPut(path6, body) {
5644
- return client.request("PUT", path6, body);
5663
+ async function internalPut(path7, body) {
5664
+ return client.request("PUT", path7, body);
5645
5665
  }
5646
- async function internalDelete(path6) {
5647
- return client.request("DELETE", path6);
5666
+ async function internalDelete(path7) {
5667
+ return client.request("DELETE", path7);
5648
5668
  }
5649
- async function funnelRequest(method, path6, body) {
5669
+ async function funnelRequest(method, path7, body) {
5650
5670
  const headers = await client.buildHeaders();
5651
5671
  headers.Origin = "https://app.gohighlevel.com";
5652
5672
  headers.Referer = "https://app.gohighlevel.com/";
5653
- const url = `https://backend.leadconnectorhq.com/funnels${path6}`;
5673
+ const url = `https://backend.leadconnectorhq.com/funnels${path7}`;
5654
5674
  const options = { method, headers };
5655
5675
  if (body && (method === "POST" || method === "PUT")) {
5656
5676
  options.body = JSON.stringify(body);
@@ -5658,7 +5678,7 @@ function registerFunnelBuilderTools(server2, builderClient) {
5658
5678
  const response = await fetch(url, options);
5659
5679
  if (!response.ok) {
5660
5680
  const text2 = await response.text();
5661
- throw new Error(`Funnel API Error ${response.status}: ${method} ${path6}
5681
+ throw new Error(`Funnel API Error ${response.status}: ${method} ${path7}
5662
5682
  ${text2}`);
5663
5683
  }
5664
5684
  const text = await response.text();
@@ -5950,9 +5970,9 @@ function buildUpdateFormBody(name, formData) {
5950
5970
  function registerFormBuilderTools(server2, builderClient) {
5951
5971
  const client = builderClient;
5952
5972
  if (!client) return;
5953
- async function formRequest(method, path6, body) {
5973
+ async function formRequest(method, path7, body) {
5954
5974
  const headers = await client.buildHeaders();
5955
- const url = `https://backend.leadconnectorhq.com/forms${path6}`;
5975
+ const url = `https://backend.leadconnectorhq.com/forms${path7}`;
5956
5976
  const options = { method, headers };
5957
5977
  if (body && (method === "POST" || method === "PUT")) {
5958
5978
  options.body = JSON.stringify(body);
@@ -5960,7 +5980,7 @@ function registerFormBuilderTools(server2, builderClient) {
5960
5980
  const response = await fetch(url, options);
5961
5981
  if (!response.ok) {
5962
5982
  const text2 = await response.text();
5963
- throw new Error(`Form API Error ${response.status}: ${method} ${path6}
5983
+ throw new Error(`Form API Error ${response.status}: ${method} ${path7}
5964
5984
  ${text2}`);
5965
5985
  }
5966
5986
  const text = await response.text();
@@ -6074,10 +6094,10 @@ ${text2}`);
6074
6094
  },
6075
6095
  async ({ formId, limit, skip }) => {
6076
6096
  try {
6077
- let path6 = `/submissions?locationId=${client.locationId}&limit=${limit ?? 20}`;
6078
- if (formId) path6 += `&formId=${formId}`;
6079
- if (skip) path6 += `&skip=${skip}`;
6080
- const result = await formRequest("GET", path6);
6097
+ let path7 = `/submissions?locationId=${client.locationId}&limit=${limit ?? 20}`;
6098
+ if (formId) path7 += `&formId=${formId}`;
6099
+ if (skip) path7 += `&skip=${skip}`;
6100
+ const result = await formRequest("GET", path7);
6081
6101
  return {
6082
6102
  content: [{ type: "text", text: JSON.stringify(result, null, 2) }]
6083
6103
  };
@@ -6093,9 +6113,9 @@ var import_zod36 = require("zod");
6093
6113
  function registerPipelineBuilderTools(server2, builderClient) {
6094
6114
  const client = builderClient;
6095
6115
  if (!client) return;
6096
- async function pipelineRequest(method, path6, body) {
6116
+ async function pipelineRequest(method, path7, body) {
6097
6117
  const headers = await client.buildHeaders();
6098
- const url = `https://backend.leadconnectorhq.com/opportunities/pipelines${path6}`;
6118
+ const url = `https://backend.leadconnectorhq.com/opportunities/pipelines${path7}`;
6099
6119
  const options = { method, headers };
6100
6120
  if (body && (method === "POST" || method === "PUT" || method === "PATCH")) {
6101
6121
  options.body = JSON.stringify(body);
@@ -6103,7 +6123,7 @@ function registerPipelineBuilderTools(server2, builderClient) {
6103
6123
  const response = await fetch(url, options);
6104
6124
  if (!response.ok) {
6105
6125
  const text2 = await response.text();
6106
- throw new Error(`Pipeline API Error ${response.status}: ${method} ${path6}
6126
+ throw new Error(`Pipeline API Error ${response.status}: ${method} ${path7}
6107
6127
  ${text2}`);
6108
6128
  }
6109
6129
  const text = await response.text();
@@ -7665,9 +7685,9 @@ var OBJECT_KEYS = ["contacts", "opportunity"];
7665
7685
  function registerSmartListTools(server2, builderClient) {
7666
7686
  const client = builderClient;
7667
7687
  if (!client) return;
7668
- async function smartListRequest(method, path6, body) {
7688
+ async function smartListRequest(method, path7, body) {
7669
7689
  const headers = await client.buildHeaders();
7670
- const url = `${SMARTLIST_BASE}${path6}`;
7690
+ const url = `${SMARTLIST_BASE}${path7}`;
7671
7691
  const options = { method, headers };
7672
7692
  if (body && (method === "POST" || method === "PUT")) {
7673
7693
  options.body = JSON.stringify(body);
@@ -7675,7 +7695,7 @@ function registerSmartListTools(server2, builderClient) {
7675
7695
  const response = await fetch(url, options);
7676
7696
  if (!response.ok) {
7677
7697
  const text2 = await response.text();
7678
- throw new Error(`Smart Lists API Error ${response.status}: ${method} ${path6}
7698
+ throw new Error(`Smart Lists API Error ${response.status}: ${method} ${path7}
7679
7699
  ${text2}`);
7680
7700
  }
7681
7701
  const text = await response.text();
@@ -7807,12 +7827,12 @@ var REPUTATION_BASE = "https://backend.leadconnectorhq.com/reputation";
7807
7827
  function registerReputationTools(server2, builderClient) {
7808
7828
  const client = builderClient;
7809
7829
  if (!client) return;
7810
- async function reputationRequest(method, path6) {
7830
+ async function reputationRequest(method, path7) {
7811
7831
  const headers = await client.buildHeaders();
7812
- const response = await fetch(`${REPUTATION_BASE}${path6}`, { method, headers });
7832
+ const response = await fetch(`${REPUTATION_BASE}${path7}`, { method, headers });
7813
7833
  if (!response.ok) {
7814
7834
  const text2 = await response.text();
7815
- throw new Error(`Reputation API Error ${response.status}: ${method} ${path6}
7835
+ throw new Error(`Reputation API Error ${response.status}: ${method} ${path7}
7816
7836
  ${text2}`);
7817
7837
  }
7818
7838
  const text = await response.text();
@@ -7927,16 +7947,16 @@ var MEMBERSHIP_BASE = "https://backend.leadconnectorhq.com/membership";
7927
7947
  function registerMembershipTools(server2, builderClient) {
7928
7948
  const client = builderClient;
7929
7949
  if (!client) return;
7930
- async function membershipRequest(path6, method = "GET", body) {
7950
+ async function membershipRequest(path7, method = "GET", body) {
7931
7951
  const headers = await client.buildHeaders();
7932
- const response = await fetch(`${MEMBERSHIP_BASE}${path6}`, {
7952
+ const response = await fetch(`${MEMBERSHIP_BASE}${path7}`, {
7933
7953
  method,
7934
7954
  headers,
7935
7955
  body: body ? JSON.stringify(body) : void 0
7936
7956
  });
7937
7957
  if (!response.ok) {
7938
7958
  const text2 = await response.text();
7939
- throw new Error(`Membership API Error ${response.status}: ${method} ${path6}
7959
+ throw new Error(`Membership API Error ${response.status}: ${method} ${path7}
7940
7960
  ${text2}`);
7941
7961
  }
7942
7962
  const text = await response.text();
@@ -9494,12 +9514,301 @@ function registerMetaTools(server2, installedVersion) {
9494
9514
  );
9495
9515
  }
9496
9516
 
9517
+ // src/cli.ts
9518
+ var import_node_util = require("node:util");
9519
+ var fs5 = __toESM(require("fs"));
9520
+ var path5 = __toESM(require("path"));
9521
+ var import_crypto2 = require("crypto");
9522
+ var EXIT_OK = 0;
9523
+ var EXIT_USAGE = 2;
9524
+ var EXIT_VALIDATION = 3;
9525
+ var EXIT_FS = 4;
9526
+ var USAGE = `Usage: ghl-mcp cli <subcommand> [options]
9527
+
9528
+ Subcommands:
9529
+ register-location Add a sub-account's Private Integration key
9530
+ --location-id <id> GHL Location ID (required)
9531
+ --api-key <pit-...> The sub-account's Private Integration key (required)
9532
+ --name <name> Friendly name (required)
9533
+ --company-id <id> Owning company ID (optional; auto-resolved when validating)
9534
+ --no-validate Skip the live GHL check (air-gapped; local-admin-only)
9535
+
9536
+ register-company-firebase Add a company's workflow-builder (Firebase) credentials
9537
+ --company-id <id> GHL company/agency ID (required; self-corrects to the
9538
+ token's real company when validating)
9539
+ --refresh-token <tok> Firebase refresh token (required)
9540
+ --user-id <uid> Firebase user ID (required)
9541
+ --api-key <AIza...> Firebase API key (optional if the home install has one)
9542
+ --name <name> Friendly company name (optional)
9543
+ --no-validate Skip the live token-mint check
9544
+
9545
+ register-agency-key Store the AGENCY-level API key (snapshots, agency reads)
9546
+ --api-key <pit-...> Agency-level Private Integration key (required)
9547
+ --no-validate Skip the live agency-scope check
9548
+
9549
+ list-locations Print registered locations / companies (never prints keys)
9550
+
9551
+ Exit codes: 0 ok, 2 usage, 3 validation failed, 4 filesystem write failed.
9552
+ Seed while the MCP server is stopped, or restart it afterwards.`;
9553
+ function errLine(msg) {
9554
+ process.stderr.write(msg + "\n");
9555
+ }
9556
+ function preflightWritable() {
9557
+ try {
9558
+ const dir = ensureAppDataDir();
9559
+ const probe = path5.join(dir, `.write-probe.${process.pid}.${(0, import_crypto2.randomBytes)(4).toString("hex")}`);
9560
+ fs5.writeFileSync(probe, "ok");
9561
+ fs5.unlinkSync(probe);
9562
+ return true;
9563
+ } catch (error) {
9564
+ errLine(`Config dir is not writable: ${error instanceof Error ? error.message : String(error)}`);
9565
+ errLine(`Config dir resolves to the parent of: ${tokenRegistryPath()}`);
9566
+ errLine("If you set GHL_MCP_CONFIG_DIR, make sure it is an absolute path on a writable volume.");
9567
+ return false;
9568
+ }
9569
+ }
9570
+ function restartReminder() {
9571
+ errLine("Note: if the MCP server is currently running, restart it to pick up this change");
9572
+ errLine("(registry writes are last-writer-wins across processes \u2014 seed stopped, or restart after).");
9573
+ }
9574
+ function confirmSaved(registry2) {
9575
+ const err = registry2.getLastSaveError();
9576
+ if (err) {
9577
+ errLine(`Failed to write registry: ${err}`);
9578
+ return false;
9579
+ }
9580
+ return true;
9581
+ }
9582
+ function parse(argv, options, required) {
9583
+ let parsed;
9584
+ try {
9585
+ parsed = (0, import_node_util.parseArgs)({ args: argv, options, strict: true, allowPositionals: false });
9586
+ } catch (error) {
9587
+ return { usageError: error instanceof Error ? error.message : String(error) };
9588
+ }
9589
+ for (const key of required) {
9590
+ const v = parsed.values[key];
9591
+ if (typeof v !== "string" || v.trim() === "") {
9592
+ return { usageError: `Missing required option: --${key}` };
9593
+ }
9594
+ }
9595
+ return parsed;
9596
+ }
9597
+ async function cmdRegisterLocation(argv, registry2) {
9598
+ const p = parse(
9599
+ argv,
9600
+ {
9601
+ "location-id": { type: "string" },
9602
+ "api-key": { type: "string" },
9603
+ name: { type: "string" },
9604
+ "company-id": { type: "string" },
9605
+ "no-validate": { type: "boolean" }
9606
+ },
9607
+ ["location-id", "api-key", "name"]
9608
+ );
9609
+ if ("usageError" in p) {
9610
+ errLine(p.usageError);
9611
+ errLine(USAGE);
9612
+ return EXIT_USAGE;
9613
+ }
9614
+ const locationId2 = p.values["location-id"].trim();
9615
+ const apiKey2 = p.values["api-key"].trim();
9616
+ let name = p.values.name.trim();
9617
+ let companyId = p.values["company-id"]?.trim() || void 0;
9618
+ if (!p.values["no-validate"]) {
9619
+ const client = new GHLClient({ apiKey: apiKey2, locationId: locationId2 });
9620
+ try {
9621
+ const result = await client.get(`/locations/${locationId2}`);
9622
+ const loc = result.location ?? result;
9623
+ if (typeof loc?.name === "string" && loc.name) name = loc.name;
9624
+ if (!companyId && typeof loc?.companyId === "string") companyId = loc.companyId;
9625
+ } catch (error) {
9626
+ errLine(`Validation failed: ${error instanceof Error ? error.message : String(error)}`);
9627
+ errLine("The key must be a Private Integration created INSIDE this sub-account, and the");
9628
+ errLine("Location ID must match. Use --no-validate only if this machine cannot reach GHL.");
9629
+ return EXIT_VALIDATION;
9630
+ }
9631
+ }
9632
+ if (!preflightWritable()) return EXIT_FS;
9633
+ try {
9634
+ registry2.registerLocation(locationId2, name, apiKey2, companyId);
9635
+ } catch (error) {
9636
+ errLine(`Failed to write registry: ${error instanceof Error ? error.message : String(error)}`);
9637
+ return EXIT_FS;
9638
+ }
9639
+ if (!confirmSaved(registry2)) return EXIT_FS;
9640
+ process.stdout.write(
9641
+ `Registered location: ${name} (${locationId2})${companyId ? ` company ${companyId}` : ""} \u2192 ${tokenRegistryPath()}
9642
+ `
9643
+ );
9644
+ restartReminder();
9645
+ return EXIT_OK;
9646
+ }
9647
+ async function cmdRegisterCompanyFirebase(argv, registry2) {
9648
+ const p = parse(
9649
+ argv,
9650
+ {
9651
+ "company-id": { type: "string" },
9652
+ "refresh-token": { type: "string" },
9653
+ "user-id": { type: "string" },
9654
+ "api-key": { type: "string" },
9655
+ name: { type: "string" },
9656
+ "no-validate": { type: "boolean" }
9657
+ },
9658
+ ["company-id", "refresh-token", "user-id"]
9659
+ );
9660
+ if ("usageError" in p) {
9661
+ errLine(p.usageError);
9662
+ errLine(USAGE);
9663
+ return EXIT_USAGE;
9664
+ }
9665
+ const typedCompanyId = p.values["company-id"].trim();
9666
+ const refreshToken = p.values["refresh-token"].trim();
9667
+ const userId = p.values["user-id"].trim();
9668
+ const name = p.values.name?.trim();
9669
+ const apiKey2 = p.values["api-key"]?.trim() || process.env.GHL_FIREBASE_API_KEY || registry2.getFirebase()?.apiKey;
9670
+ if (!apiKey2) {
9671
+ errLine("No Firebase API key available. Pass --api-key (starts with 'AIza'), or seed the home");
9672
+ errLine("Firebase first (the key is identical across GHL accounts).");
9673
+ return EXIT_USAGE;
9674
+ }
9675
+ let canonicalCompanyId = typedCompanyId;
9676
+ if (!p.values["no-validate"]) {
9677
+ const fb = await validateFirebase(apiKey2, refreshToken);
9678
+ if (!fb.ok) {
9679
+ errLine(`Firebase credentials rejected: ${fb.error}`);
9680
+ errLine("Capture fresh values from a browser session logged into THIS company's GHL:");
9681
+ errLine("https://elitedcs.com/ghl-mcp-firebase");
9682
+ return EXIT_VALIDATION;
9683
+ }
9684
+ if (fb.companyId && fb.companyId !== typedCompanyId) {
9685
+ canonicalCompanyId = fb.companyId;
9686
+ errLine(
9687
+ `Note: stored under company ${fb.companyId} (the ID this token actually authenticates as), not ${typedCompanyId}.`
9688
+ );
9689
+ }
9690
+ } else {
9691
+ errLine("Warning: --no-validate stores the typed company ID verbatim. If workflow-builder calls");
9692
+ errLine("401 after seeding, re-run WITHOUT --no-validate so the ID self-corrects from the token.");
9693
+ }
9694
+ if (!preflightWritable()) return EXIT_FS;
9695
+ try {
9696
+ registry2.setCompanyFirebase(canonicalCompanyId, {
9697
+ apiKey: apiKey2,
9698
+ refreshToken,
9699
+ userId,
9700
+ ...name ? { name } : {}
9701
+ });
9702
+ } catch (error) {
9703
+ errLine(`Failed to write registry: ${error instanceof Error ? error.message : String(error)}`);
9704
+ return EXIT_FS;
9705
+ }
9706
+ if (!confirmSaved(registry2)) return EXIT_FS;
9707
+ process.stdout.write(
9708
+ `Registered company Firebase: ${name ?? canonicalCompanyId} (${canonicalCompanyId}) \u2192 ${tokenRegistryPath()}
9709
+ `
9710
+ );
9711
+ restartReminder();
9712
+ return EXIT_OK;
9713
+ }
9714
+ async function cmdRegisterAgencyKey(argv, registry2) {
9715
+ const p = parse(
9716
+ argv,
9717
+ { "api-key": { type: "string" }, "no-validate": { type: "boolean" } },
9718
+ ["api-key"]
9719
+ );
9720
+ if ("usageError" in p) {
9721
+ errLine(p.usageError);
9722
+ errLine(USAGE);
9723
+ return EXIT_USAGE;
9724
+ }
9725
+ const apiKey2 = p.values["api-key"].trim();
9726
+ if (!p.values["no-validate"]) {
9727
+ const client = new GHLClient({ apiKey: apiKey2 });
9728
+ try {
9729
+ const probe = await client.get("/locations/search", { params: { limit: 1, skip: 0 } });
9730
+ const locations = probe?.locations;
9731
+ if (!Array.isArray(locations)) {
9732
+ throw new Error("response is not an agency location-search envelope (no locations array)");
9733
+ }
9734
+ } catch (error) {
9735
+ errLine(`Validation failed: ${error instanceof Error ? error.message : String(error)}`);
9736
+ errLine("The key must be a Private Integration created at the AGENCY level (Agency Settings >");
9737
+ errLine("Private Integrations), with the locations scope enabled.");
9738
+ return EXIT_VALIDATION;
9739
+ }
9740
+ }
9741
+ if (!preflightWritable()) return EXIT_FS;
9742
+ try {
9743
+ registry2.setAgencyKey(apiKey2);
9744
+ } catch (error) {
9745
+ errLine(`Failed to write registry: ${error instanceof Error ? error.message : String(error)}`);
9746
+ return EXIT_FS;
9747
+ }
9748
+ if (!confirmSaved(registry2)) return EXIT_FS;
9749
+ process.stdout.write(`Registered agency key: ${apiKey2.substring(0, 12)}... \u2192 ${tokenRegistryPath()}
9750
+ `);
9751
+ restartReminder();
9752
+ return EXIT_OK;
9753
+ }
9754
+ function cmdListLocations(registry2) {
9755
+ const locs = registry2.listLocations().map((loc) => {
9756
+ const companyId = registry2.getToken(loc.locationId)?.companyId;
9757
+ return { ...loc, ...companyId ? { companyId } : {} };
9758
+ });
9759
+ const companies = registry2.listCompanyFirebases();
9760
+ const out = {
9761
+ registryPath: tokenRegistryPath(),
9762
+ locations: locs,
9763
+ agencyKey: registry2.getAgencyKey() ? "registered" : "not registered",
9764
+ homeFirebase: registry2.getFirebase() ? "registered" : "not registered",
9765
+ // names/ids ONLY — no keys, tokens, or user ids (design contract).
9766
+ companyFirebases: companies.map(({ companyId, name }) => ({ companyId, ...name ? { name } : {} }))
9767
+ };
9768
+ process.stdout.write(JSON.stringify(out, null, 2) + "\n");
9769
+ return EXIT_OK;
9770
+ }
9771
+ async function runCli(subcommand, argv) {
9772
+ let registry2;
9773
+ try {
9774
+ registry2 = new TokenRegistry();
9775
+ } catch (error) {
9776
+ errLine(error instanceof Error ? error.message : String(error));
9777
+ return EXIT_USAGE;
9778
+ }
9779
+ const loadFailure = registry2.getLoadFailure();
9780
+ if (loadFailure) {
9781
+ errLine(loadFailure);
9782
+ return EXIT_FS;
9783
+ }
9784
+ switch (subcommand) {
9785
+ case "register-location":
9786
+ return cmdRegisterLocation(argv, registry2);
9787
+ case "register-company-firebase":
9788
+ return cmdRegisterCompanyFirebase(argv, registry2);
9789
+ case "register-agency-key":
9790
+ return cmdRegisterAgencyKey(argv, registry2);
9791
+ case "list-locations":
9792
+ return cmdListLocations(registry2);
9793
+ case void 0:
9794
+ case "help":
9795
+ case "--help":
9796
+ case "-h":
9797
+ process.stdout.write(USAGE + "\n");
9798
+ return subcommand === void 0 ? EXIT_USAGE : EXIT_OK;
9799
+ default:
9800
+ errLine(`Unknown subcommand: ${subcommand}`);
9801
+ errLine(USAGE);
9802
+ return EXIT_USAGE;
9803
+ }
9804
+ }
9805
+
9497
9806
  // src/index.ts
9498
9807
  var bundledPkg = require_package();
9499
9808
  var pkg = (() => {
9500
9809
  try {
9501
9810
  const onDisk = JSON.parse(
9502
- fs5.readFileSync(path5.resolve(__dirname, "..", "package.json"), "utf8")
9811
+ fs6.readFileSync(path6.resolve(__dirname, "..", "package.json"), "utf8")
9503
9812
  );
9504
9813
  if (typeof onDisk.version === "string" && onDisk.version.length > 0) {
9505
9814
  return { version: onDisk.version };
@@ -9509,25 +9818,35 @@ var pkg = (() => {
9509
9818
  return bundledPkg;
9510
9819
  })();
9511
9820
  dotenv2.config();
9821
+ {
9822
+ const configDirOverride = process.env.GHL_MCP_CONFIG_DIR?.trim();
9823
+ if (configDirOverride && !path6.isAbsolute(configDirOverride)) {
9824
+ process.stderr.write(
9825
+ `[ghl-mcp] GHL_MCP_CONFIG_DIR must be an absolute path (got "${configDirOverride}"). Use e.g. /data/ghl-mcp in a container, with a volume mounted at /data.
9826
+ `
9827
+ );
9828
+ process.exit(2);
9829
+ }
9830
+ }
9512
9831
  process.on("unhandledRejection", (reason) => {
9513
9832
  process.stderr.write(`[ghl-mcp] Unhandled rejection: ${reason}
9514
9833
  `);
9515
9834
  });
9516
9835
  function hardenSecretFilePerms() {
9517
- const repoDir = path5.resolve(__dirname, "..");
9836
+ const repoDir = path6.resolve(__dirname, "..");
9518
9837
  const candidates = [
9519
- { file: path5.join(repoDir, "start-mcp.sh"), mode: 448 },
9838
+ { file: path6.join(repoDir, "start-mcp.sh"), mode: 448 },
9520
9839
  // Legacy registry location (pre-migration); new location lives in app-data.
9521
- { file: path5.join(repoDir, ".ghl-tokens.json"), mode: 384 },
9840
+ { file: path6.join(repoDir, ".ghl-tokens.json"), mode: 384 },
9522
9841
  { file: tokenRegistryPath(), mode: 384 }
9523
9842
  ];
9524
9843
  for (const { file, mode } of candidates) {
9525
9844
  let current;
9526
9845
  try {
9527
- if (!fs5.existsSync(file)) continue;
9528
- current = fs5.statSync(file).mode & 511;
9846
+ if (!fs6.existsSync(file)) continue;
9847
+ current = fs6.statSync(file).mode & 511;
9529
9848
  if (current !== mode) {
9530
- fs5.chmodSync(file, mode);
9849
+ fs6.chmodSync(file, mode);
9531
9850
  }
9532
9851
  } catch (error) {
9533
9852
  const message = error instanceof Error ? error.message : String(error);
@@ -9741,9 +10060,28 @@ async function checkForUpdates() {
9741
10060
  }
9742
10061
  }
9743
10062
  async function main() {
10063
+ if (process.argv[2] === "cli") {
10064
+ process.exit(await runCli(process.argv[3], process.argv.slice(4)));
10065
+ }
9744
10066
  await resolveAccessAndRegister();
9745
10067
  const transport = new import_stdio.StdioServerTransport();
9746
10068
  await server.connect(transport);
10069
+ let shuttingDown = false;
10070
+ const shutdown = async (signal) => {
10071
+ if (shuttingDown) return;
10072
+ shuttingDown = true;
10073
+ process.stderr.write(`[ghl-mcp] ${signal} received \u2014 shutting down.
10074
+ `);
10075
+ const deadline = setTimeout(() => process.exit(0), 3e3);
10076
+ deadline.unref();
10077
+ try {
10078
+ await server.close();
10079
+ } catch {
10080
+ }
10081
+ process.exit(0);
10082
+ };
10083
+ process.on("SIGTERM", () => void shutdown("SIGTERM"));
10084
+ process.on("SIGINT", () => void shutdown("SIGINT"));
9747
10085
  if (inBootstrapMode) {
9748
10086
  process.stderr.write(`[ghl-mcp] v${pkg.version} connected (bootstrap mode \u2014 only setup_ghl_mcp available).
9749
10087
  `);
@@ -9752,7 +10090,9 @@ async function main() {
9752
10090
  process.stderr.write(`[ghl-mcp] v${pkg.version} connected. Token registry: ${locCount} location(s).
9753
10091
  `);
9754
10092
  validateApiKey();
9755
- checkForUpdates();
10093
+ if (process.env.GHL_MCP_DISABLE_UPDATE_CHECK !== "1") {
10094
+ checkForUpdates();
10095
+ }
9756
10096
  }
9757
10097
  }
9758
10098
  main().catch((error) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@elitedcs/ghl-mcp",
3
- "version": "3.33.0",
3
+ "version": "3.34.1",
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",
@@ -16,7 +16,7 @@
16
16
  "CHANGELOG.md"
17
17
  ],
18
18
  "scripts": {
19
- "build": "esbuild src/index.ts --bundle --platform=node --target=node22 --format=cjs --outfile=dist/index.js --packages=external",
19
+ "build": "esbuild src/index.ts --bundle --platform=node --target=node20 --format=cjs --outfile=dist/index.js --packages=external",
20
20
  "setup": "node setup-wizard.mjs",
21
21
  "start": "node dist/index.js",
22
22
  "dev": "tsc --watch",
@@ -54,7 +54,7 @@
54
54
  "access": "public"
55
55
  },
56
56
  "engines": {
57
- "node": ">=22"
57
+ "node": ">=20"
58
58
  },
59
59
  "dependencies": {
60
60
  "@modelcontextprotocol/sdk": "^1.12.1",