@elitedcs/ghl-mcp 3.25.0 → 3.26.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.
Files changed (3) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/dist/index.js +102 -54
  3. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -1,5 +1,33 @@
1
1
  # Changelog
2
2
 
3
+ ## 3.26.0 — Codex adversarial audit fixes
4
+
5
+ **Tool count unchanged (213). No new features — closes a set of silent bugs Codex flagged in a third-party adversarial review of the v3.21.0–v3.25.0 release arc.**
6
+
7
+ Now that paid customers run this code, we ran the same Codex-rescue review pattern we use on the PN tracker app. Codex turned up 10 issues; v3.26.0 ships the ones with concrete fixes. Deferred items (server-outage recovery, rate-limiting at the edge) are documented in the project memory.
8
+
9
+ **Security tightening:**
10
+
11
+ - **Cancelled-subscription detection.** Before: a cancelled license kept running for up to 44 days (30-day attestation TTL + 14-day grace) because the MCP never reacted to a server "cancelled" response — it just kept using the cached attestation until expiry. Now: `validateLicense` distinguishes `cancelled` / `unauthorized` / `unreachable`, and on `cancelled`/`unauthorized` the cached attestation is wiped from `credentials.json` so the next restart drops to bootstrap. The buyer's current session keeps running (we don't kill tools mid-call); the gate latches on next launch.
12
+ - **Background renewal on every startup.** Previously only triggered when the attestation was nearing expiry. Now every startup fires a fire-and-forget renewal so cancellations propagate within one restart instead of up-to-44-days.
13
+ - **Fail-closed when the server stops issuing signed attestations.** Before: if `ATTESTATION_PRIVATE_KEY` ever dropped off Cloudflare (env reset, accidental rotation, partial deploy), validate-license would still return `valid:true` without a `signed_attestation` and the MCP would silently fall back to legacy trust — re-opening the credentials.json bypass v3.24.0 closed. Now: after the `TRANSITIONAL_ATTESTATION_DEADLINE` (2026-06-15), an unsigned `valid:true` response is treated as bootstrap; before the deadline we still accept it for migration but log a warning with the cutoff date.
14
+
15
+ **Correctness:**
16
+
17
+ - **Atomic `credentials.json` writes.** Replaced the bare `writeFileSync` with write-to-temp-then-rename plus process-unique temp suffixes. Mid-write crashes can no longer corrupt the file, and concurrent writers (e.g. `enable_workflow_builder` racing a Firebase token rotation) can't leave it half-formed. New tests in `credentials-store.test.ts` cover both the happy path and a 20-way concurrent-write fan-out.
18
+ - **`update_calendar` schema is now `.strict()`.** Unknown fields on `teamMembers` or `locationConfigurations` (e.g. `userid` instead of `userId`, or a new GHL field we haven't captured) now error at the MCP boundary with a useful 422 instead of being silently dropped before the GHL PUT. Buyers stop wondering why a field they passed didn't save.
19
+
20
+ **Observability:**
21
+
22
+ - **`pipeline_skip` telemetry event.** When `validate-license` can't advance a buyer's pipeline opportunity (no opportunity exists, or all opportunities are closed), we now emit a distinct `[telemetry] pipeline_skip` line with the reason. Lets us surface "buyer's opportunity was accidentally closed" cases for manual reopen instead of silently failing forever.
23
+ - **Telemetry key-name redaction.** The `recordEvent` helper now scrubs any `extra` key whose name matches `/email|license|key|token|secret|refresh|password|pit/i` before logging. Belt-and-suspenders against a future caller accidentally passing a secret as a metric tag.
24
+
25
+ **UX:**
26
+
27
+ - **`enable_workflow_builder` restart messaging.** Now explicit that workflow-builder tools called BEFORE the restart will keep using the old Firebase auth and 401 — even though the tool reported success. Removes the "I ran the tool, why doesn't it work" confusion.
28
+ - **Welcome email "fifth field" not "sixth."** Off-by-one in the advanced-shortcut paragraph corrected.
29
+ - **Firebase capture script handles multiple GHL logins.** Previously the script returned the first matching `firebase:authUser:` row, which could be the wrong account if the buyer had multiple GHL logins in the same browser. Now it enumerates ALL candidates, prints each with email + uid + last-login timestamp, picks the first one (still freshest by Firebase's storage order), and tells the buyer how to re-run against a different account.
30
+
3
31
  ## 3.25.0 — One-paste Firebase capture
4
32
 
5
33
  **New bootstrap-and-normal-mode tool. Tool count goes from 212 to 213 (the new tool counts whether you're set up or not).**
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.25.0",
34
+ version: "3.26.0",
35
35
  mcpName: "io.github.drjerryrelth/ghl-command",
36
36
  description: "GoHighLevel MCP Server for Claude. 212 tools \u2014 full CRM, automation, marketing control, and the only programmatic GHL workflow builder, now multi-tenant across client accounts.",
37
37
  main: "dist/index.js",
@@ -359,12 +359,22 @@ function foldCredentialsIntoEnv(creds, env = process.env) {
359
359
  function writeCredentials(creds) {
360
360
  ensureAppDataDir();
361
361
  const file = credentialsPath();
362
- fs.writeFileSync(file, JSON.stringify(creds, null, 2) + "\n", "utf-8");
363
- if (process.platform !== "win32") {
362
+ const tmp = `${file}.tmp.${process.pid}.${Date.now()}`;
363
+ try {
364
+ fs.writeFileSync(tmp, JSON.stringify(creds, null, 2) + "\n", "utf-8");
365
+ if (process.platform !== "win32") {
366
+ try {
367
+ fs.chmodSync(tmp, 384);
368
+ } catch {
369
+ }
370
+ }
371
+ fs.renameSync(tmp, file);
372
+ } catch (err) {
364
373
  try {
365
- fs.chmodSync(file, 384);
374
+ fs.unlinkSync(tmp);
366
375
  } catch {
367
376
  }
377
+ throw err;
368
378
  }
369
379
  }
370
380
 
@@ -2398,14 +2408,14 @@ var TeamMemberLocationConfigSchema = import_zod9.z.object({
2398
2408
  kind: import_zod9.z.string().describe("Meeting location kind. Common values: 'custom', 'zoom_conference', 'google_meet'."),
2399
2409
  position: import_zod9.z.number().int().nonnegative().describe("Position index for ordering (0-based)."),
2400
2410
  location: import_zod9.z.string().optional().describe("Display string for the meeting location (URL or address). Optional for kind='custom'.")
2401
- });
2411
+ }).strict();
2402
2412
  var CalendarTeamMemberSchema = import_zod9.z.object({
2403
2413
  userId: import_zod9.z.string().describe("GHL user ID to assign. Must exist in this sub-account."),
2404
2414
  priority: import_zod9.z.union([import_zod9.z.literal(0), import_zod9.z.literal(0.5), import_zod9.z.literal(1)]).describe("Booking priority. Exactly 0, 0.5, or 1 (anything else 422s from GHL). Higher = preferred for round-robin."),
2405
2415
  isPrimary: import_zod9.z.boolean().describe("Whether this user is the primary assignee."),
2406
2416
  selected: import_zod9.z.boolean().describe("Whether this user is currently active on the calendar (true to enable)."),
2407
2417
  locationConfigurations: import_zod9.z.array(TeamMemberLocationConfigSchema).min(1).describe("At least one meeting-location config. Typical shape: [{kind:'custom', position:0, location:''}].")
2408
- });
2418
+ }).strict();
2409
2419
  function registerCalendarTools(server2, client) {
2410
2420
  safeTool(
2411
2421
  server2,
@@ -5844,25 +5854,35 @@ var FIREBASE_CAPTURE_SCRIPT = `(async () => {
5844
5854
  r.onsuccess = () => res(r.result);
5845
5855
  r.onerror = () => rej(r.error);
5846
5856
  });
5847
- const row = rows.find(r => typeof r?.fbase_key === 'string' && r.fbase_key.startsWith('firebase:authUser:AIza'));
5848
- if (!row) {
5857
+ const candidates = rows.filter(r => typeof r?.fbase_key === 'string' && r.fbase_key.startsWith('firebase:authUser:AIza') && r?.value?.apiKey && r?.value?.uid && r?.value?.stsTokenManager?.refreshToken);
5858
+ if (!candidates.length) {
5849
5859
  console.log('%cGHL Command: no Firebase login found.', 'color:red;font-weight:bold');
5850
5860
  console.log('Open a tab logged into your GHL account, then run this again in that tab.');
5851
5861
  return;
5852
5862
  }
5853
- const v = row.value || {};
5863
+ // Multi-account safety (v3.26.0): if the browser has more than one
5864
+ // GHL Firebase login cached, show the buyer which one we're using and
5865
+ // how to pick a different one. The first matching row is the freshest
5866
+ // login per Firebase's storage order, but it isn't always the buyer's
5867
+ // CURRENT account if they recently switched.
5868
+ if (candidates.length > 1) {
5869
+ console.log('%cGHL Command: ' + candidates.length + ' Firebase logins found in this browser:', 'color:orange;font-weight:bold');
5870
+ candidates.forEach((c, i) => {
5871
+ const v = c.value || {};
5872
+ const lastLogin = v.lastLoginAt ? new Date(Number(v.lastLoginAt)).toISOString() : '(no timestamp)';
5873
+ console.log(' [' + i + '] ' + (v.email || '(no email)') + ' \u2014 uid ' + (v.uid || '').slice(0, 8) + '\u2026 \u2014 last login ' + lastLogin);
5874
+ });
5875
+ console.log('Using [0] (most recently active). If that is the wrong account, log out of the others in their GHL tabs and re-run this script.');
5876
+ }
5877
+ const v = candidates[0].value;
5854
5878
  const out = {
5855
5879
  ghl_firebase_api_key: v.apiKey,
5856
5880
  ghl_user_id: v.uid,
5857
- ghl_firebase_refresh_token: v.stsTokenManager?.refreshToken,
5881
+ ghl_firebase_refresh_token: v.stsTokenManager.refreshToken,
5858
5882
  };
5859
- if (!out.ghl_firebase_api_key || !out.ghl_user_id || !out.ghl_firebase_refresh_token) {
5860
- console.log('%cGHL Command: found Firebase login but a field was missing.', 'color:red;font-weight:bold');
5861
- console.log('Try logging out of GHL and back in, then run this again.');
5862
- return;
5863
- }
5864
5883
  const json = JSON.stringify(out, null, 2);
5865
5884
  console.log('%cGHL Command: paste this into setup_ghl_mcp (firebase_paste field):', 'color:green;font-weight:bold');
5885
+ if (v.email) console.log('Account: ' + v.email);
5866
5886
  console.log(json);
5867
5887
  try {
5868
5888
  await navigator.clipboard.writeText(json);
@@ -5914,8 +5934,9 @@ function deviceFingerprint() {
5914
5934
  return crypto2.createHash("sha256").update(raw).digest("hex").slice(0, 16);
5915
5935
  }
5916
5936
  async function validateLicense(email, licenseKey) {
5937
+ let res;
5917
5938
  try {
5918
- const res = await fetch(LICENSE_API, {
5939
+ res = await fetch(LICENSE_API, {
5919
5940
  method: "POST",
5920
5941
  headers: { "Content-Type": "application/json" },
5921
5942
  body: JSON.stringify({
@@ -5925,19 +5946,28 @@ async function validateLicense(email, licenseKey) {
5925
5946
  }),
5926
5947
  signal: AbortSignal.timeout(1e4)
5927
5948
  });
5928
- const data = await res.json().catch(() => ({}));
5929
- if (res.ok && data.valid) {
5930
- return {
5931
- ok: true,
5932
- installs: `${data.installs_used}/${data.installs_max}`,
5933
- signedAttestation: typeof data.signed_attestation === "string" ? data.signed_attestation : void 0
5934
- };
5935
- }
5936
- return { ok: false, error: data.error || data.message || `License validation failed (HTTP ${res.status})` };
5937
5949
  } catch (err) {
5938
5950
  const msg = err instanceof Error ? err.message : String(err);
5939
- return { ok: false, error: `Could not reach license server: ${msg}` };
5951
+ return { ok: false, error: `Could not reach license server: ${msg}`, reason: "unreachable" };
5952
+ }
5953
+ if (res.status >= 500) {
5954
+ return { ok: false, error: `License server returned HTTP ${res.status}`, reason: "unreachable" };
5940
5955
  }
5956
+ const data = await res.json().catch(() => ({}));
5957
+ if (res.ok && data.valid) {
5958
+ return {
5959
+ ok: true,
5960
+ installs: `${data.installs_used}/${data.installs_max}`,
5961
+ signedAttestation: typeof data.signed_attestation === "string" ? data.signed_attestation : void 0
5962
+ };
5963
+ }
5964
+ const errorText = String(data.error || data.message || "").toLowerCase();
5965
+ const looksCancelled = res.status === 403 || errorText.includes("cancel") || errorText.includes("install limit");
5966
+ return {
5967
+ ok: false,
5968
+ error: data.error || data.message || `License validation failed (HTTP ${res.status})`,
5969
+ reason: looksCancelled ? "cancelled" : "unauthorized"
5970
+ };
5941
5971
  }
5942
5972
  async function validateGhl(apiKey2, locationId2) {
5943
5973
  try {
@@ -6164,13 +6194,13 @@ DevTools steps: https://elitedcs.com/ghl-mcp-firebase`
6164
6194
  text: [
6165
6195
  "Workflow Builder enabled!",
6166
6196
  "",
6167
- "Firebase credentials verified and saved.",
6197
+ "Firebase credentials verified and saved to credentials.json.",
6168
6198
  "",
6169
- "**Restart Claude (quit fully and reopen) to load the workflow builder + funnel builder + pipeline builder + form builder + workflow cloner tools (212 total).**",
6199
+ "**You MUST restart Claude before using any workflow-builder tool.** Quit Claude completely (Cmd+Q on Mac, full exit on Windows) and reopen. Without a restart, the workflow builder tools will keep using the OLD Firebase auth from before this call and fail with 401 errors \u2014 even though this tool reported success.",
6170
6200
  "",
6171
- 'After restart, try: "List my workflows in full detail" or "Validate workflow <id>".',
6201
+ 'After restart, all 212 tools load. Try: "List my workflows in full detail" or "Validate workflow <id>".',
6172
6202
  "",
6173
- "Note: Firebase refresh tokens rotate every few weeks. If workflow tools stop working, re-run enable_workflow_builder with fresh values from a current GHL browser session."
6203
+ "Note: Firebase refresh tokens rotate every few weeks. If workflow tools stop working in a few weeks (run `health_check` to confirm Firebase auth: FAIL), run `auto_capture_firebase_script` for fresh values and re-run this tool with the new firebase_paste."
6174
6204
  ].join("\n")
6175
6205
  }]
6176
6206
  };
@@ -8544,12 +8574,6 @@ async function verifyAttestation(token, bindings, now = /* @__PURE__ */ new Date
8544
8574
  }
8545
8575
  return { ok: false, reason: "expired" };
8546
8576
  }
8547
- function shouldRenew(payload, now = /* @__PURE__ */ new Date()) {
8548
- const expiry = Date.parse(payload.expires_at);
8549
- if (!Number.isFinite(expiry)) return true;
8550
- const remainingMs = expiry - now.getTime();
8551
- return remainingMs < 7 * 24 * 60 * 60 * 1e3;
8552
- }
8553
8577
 
8554
8578
  // src/tools/meta.ts
8555
8579
  function registerMetaTools(server2, installedVersion) {
@@ -8633,15 +8657,16 @@ var server = new import_mcp.McpServer({
8633
8657
  });
8634
8658
  registerMetaTools(server, pkg.version);
8635
8659
  var inBootstrapMode = true;
8660
+ var TRANSITIONAL_ATTESTATION_DEADLINE = Date.parse("2026-06-15T00:00:00.000Z");
8636
8661
  async function renewAttestation(creds) {
8637
- if (!creds.email || !creds.license_key) return false;
8662
+ if (!creds.email || !creds.license_key) return "unauthorized";
8663
+ let lic;
8638
8664
  try {
8639
- const lic = await validateLicense(creds.email, creds.license_key);
8640
- if (!lic.ok) {
8641
- process.stderr.write(`[ghl-mcp] Re-validate failed: ${lic.error}
8642
- `);
8643
- return false;
8644
- }
8665
+ lic = await validateLicense(creds.email, creds.license_key);
8666
+ } catch {
8667
+ return "unreachable";
8668
+ }
8669
+ if (lic.ok) {
8645
8670
  try {
8646
8671
  writeCredentials({
8647
8672
  ...creds,
@@ -8651,12 +8676,23 @@ async function renewAttestation(creds) {
8651
8676
  } catch {
8652
8677
  }
8653
8678
  if (!lic.signedAttestation) {
8654
- process.stderr.write("[ghl-mcp] License re-validated, but the license server did not return a signed attestation. Falling back to legacy trust for this session.\n");
8679
+ process.stderr.write("[ghl-mcp] License re-validated, but the license server did not return a signed attestation. This path closes on " + new Date(TRANSITIONAL_ATTESTATION_DEADLINE).toISOString().slice(0, 10) + ".\n");
8680
+ return "renewed-unsigned";
8655
8681
  }
8656
- return true;
8657
- } catch {
8658
- return false;
8682
+ return "renewed";
8659
8683
  }
8684
+ if (lic.reason === "cancelled" || lic.reason === "unauthorized") {
8685
+ try {
8686
+ writeCredentials({ ...creds, signed_attestation: void 0 });
8687
+ process.stderr.write(`[ghl-mcp] Server rejected license (${lic.reason}). Dropping to bootstrap mode.
8688
+ `);
8689
+ } catch {
8690
+ }
8691
+ return lic.reason;
8692
+ }
8693
+ process.stderr.write(`[ghl-mcp] License re-validate unreachable: ${lic.error}
8694
+ `);
8695
+ return "unreachable";
8660
8696
  }
8661
8697
  function renewAttestationInBackground(creds) {
8662
8698
  return renewAttestation(creds).then(() => void 0, () => void 0);
@@ -8672,14 +8708,15 @@ async function resolveAccessAndRegister() {
8672
8708
  if (verify.ok) {
8673
8709
  licenseVerified = true;
8674
8710
  attestationStatus = verify.reason;
8675
- if (verify.reason === "in-grace" || shouldRenew(verify.payload)) {
8676
- void renewAttestationInBackground(fileCreds);
8677
- }
8711
+ void renewAttestationInBackground(fileCreds);
8678
8712
  } else {
8679
8713
  process.stderr.write(`[ghl-mcp] Stored attestation rejected: ${verify.reason}. Re-validating online.
8680
8714
  `);
8681
8715
  const renewed = await renewAttestation(fileCreds);
8682
- if (renewed) {
8716
+ if (renewed === "renewed") {
8717
+ licenseVerified = true;
8718
+ attestationStatus = "renewed-after-tamper";
8719
+ } else if (renewed === "renewed-unsigned" && Date.now() < TRANSITIONAL_ATTESTATION_DEADLINE) {
8683
8720
  licenseVerified = true;
8684
8721
  attestationStatus = "renewed-after-tamper";
8685
8722
  } else {
@@ -8689,12 +8726,22 @@ async function resolveAccessAndRegister() {
8689
8726
  } else if (fileCreds?.email && fileCreds?.license_key) {
8690
8727
  process.stderr.write("[ghl-mcp] Upgrading legacy credentials.json to signed attestation.\n");
8691
8728
  const renewed = await renewAttestation(fileCreds);
8692
- if (renewed) {
8729
+ if (renewed === "renewed") {
8730
+ licenseVerified = true;
8731
+ attestationStatus = "renewed-from-legacy";
8732
+ } else if (renewed === "renewed-unsigned" && Date.now() < TRANSITIONAL_ATTESTATION_DEADLINE) {
8693
8733
  licenseVerified = true;
8694
8734
  attestationStatus = "renewed-from-legacy";
8695
8735
  } else {
8696
8736
  attestationStatus = "needs-reset";
8697
- process.stderr.write("[ghl-mcp] Could not re-validate. Run setup_ghl_mcp again.\n");
8737
+ if (renewed === "cancelled" || renewed === "unauthorized") {
8738
+ process.stderr.write(`[ghl-mcp] Server says license is ${renewed}. Bootstrap mode.
8739
+ `);
8740
+ } else if (renewed === "renewed-unsigned") {
8741
+ process.stderr.write("[ghl-mcp] Server is in unsigned-attestation mode past the transition deadline. Bootstrap mode.\n");
8742
+ } else {
8743
+ process.stderr.write("[ghl-mcp] Could not re-validate. Run setup_ghl_mcp again.\n");
8744
+ }
8698
8745
  }
8699
8746
  }
8700
8747
  if (!licenseVerified && apiKey && locationId) {
@@ -8702,7 +8749,8 @@ async function resolveAccessAndRegister() {
8702
8749
  const licKey = (process.env.GHL_LICENSE_KEY || fileCreds?.license_key)?.trim();
8703
8750
  if (licEmail && licKey) {
8704
8751
  const lic = await validateLicense(licEmail, licKey);
8705
- if (lic.ok) {
8752
+ const acceptUnsigned = !lic.ok ? false : lic.signedAttestation || Date.now() < TRANSITIONAL_ATTESTATION_DEADLINE;
8753
+ if (lic.ok && acceptUnsigned) {
8706
8754
  licenseVerified = true;
8707
8755
  attestationStatus = "renewed";
8708
8756
  try {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@elitedcs/ghl-mcp",
3
- "version": "3.25.0",
3
+ "version": "3.26.0",
4
4
  "mcpName": "io.github.drjerryrelth/ghl-command",
5
5
  "description": "GoHighLevel MCP Server for Claude. 212 tools — full CRM, automation, marketing control, and the only programmatic GHL workflow builder, now multi-tenant across client accounts.",
6
6
  "main": "dist/index.js",