@elitedcs/ghl-mcp 3.25.0 → 3.27.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/CHANGELOG.md CHANGED
@@ -1,5 +1,53 @@
1
1
  # Changelog
2
2
 
3
+ ## 3.27.0 — Multi-tenant Firebase binding fix
4
+
5
+ **Fixes the bug that blocked workflow-builder tools in client sub-accounts even after a successful `register_company_firebase`.**
6
+
7
+ **Root cause.** GHL uses two different company identifiers. The ID shown in the agency dashboard URL is NOT the `companyId` that `/locations/{id}` returns or that the Firebase token carries in its `company_id` claim. `register_company_firebase` stored the credentials under the human-typed (agency-URL) ID, but `switch_location` resolves a sub-account's owner from `/locations/{id}.companyId` — the internal ID. The two never matched, so the lookup missed, the workflow builder fell back to home auth, and every Firebase-gated call 401'd. The warning even told the user to run `register_company_firebase` again — which they already had.
8
+
9
+ **Fixes:**
10
+
11
+ - **Store under the token's real company.** `register_company_firebase` now decodes the Firebase ID token's `company_id` claim and keys the registry entry on THAT value, not the typed one. Pass any company ID you have; it self-corrects. A note tells you when the stored ID differs from what you entered.
12
+ - **Verification now exercises the real path.** The optional end-to-end test resolves the test location's company the same way `switch_location` does, confirms the registry lookup actually hits, then makes a live workflow-builder call. The old probe used an inline client that bypassed the lookup, so it passed even when the stored key would never be found.
13
+ - **PIT/Firebase mismatch detection.** Every time the client mints an ID token, it compares the token's `company_id` claim against the company we intend to act on. A mismatch (the exact signature of this bug, or any future regression) writes a loud one-time stderr warning instead of failing silently. Direct Firebase-gated tool calls bypass `switch_location`'s routing, so this lives at the token-mint layer.
14
+ - **Honest `health_check`.** Firebase auth now reports the company the token ACTUALLY authenticates as, and returns `fail` (not a bare `pass`) when that disagrees with the active company.
15
+ - **Registry key normalization.** Company IDs are trimmed on store/lookup so a stray pasted space can't fork one company into two entries.
16
+ - **Misleading warning fixed.** When a sub-account's company has no matching key, `switch_location` now distinguishes "nothing registered" from "registered under a different ID" and names the mismatch, instead of blindly telling the user to re-register.
17
+ - **Updated `register_company_firebase` description + DevTools doc** so we stop telling users to copy the company ID from the agency URL.
18
+
19
+ **Known behavior (by design):** after an MCP restart you must `switch_location` once into a client account before its workflow-builder tools work — the active company resets to home on launch. `health_check` and the mismatch warning now make this obvious.
20
+
21
+ **Tests:** new `workflow-builder-mismatch.test.ts` covers claim decoding, registry key normalization, mismatch warn/no-warn, and warn-once dedup. Full suite green (163 passing).
22
+
23
+ ## 3.26.0 — Codex adversarial audit fixes
24
+
25
+ **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.**
26
+
27
+ 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.
28
+
29
+ **Security tightening:**
30
+
31
+ - **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.
32
+ - **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.
33
+ - **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.
34
+
35
+ **Correctness:**
36
+
37
+ - **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.
38
+ - **`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.
39
+
40
+ **Observability:**
41
+
42
+ - **`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.
43
+ - **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.
44
+
45
+ **UX:**
46
+
47
+ - **`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.
48
+ - **Welcome email "fifth field" not "sixth."** Off-by-one in the advanced-shortcut paragraph corrected.
49
+ - **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.
50
+
3
51
  ## 3.25.0 — One-paste Firebase capture
4
52
 
5
53
  **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/README.md CHANGED
@@ -185,12 +185,13 @@ To unlock full builder access across multiple clients from one install:
185
185
  3. **Register the client's company Firebase:**
186
186
  ```
187
187
  register_company_firebase
188
- companyId: CLIENT_COMPANY_ID (shown in register_location's output)
188
+ companyId: CLIENT_COMPANY_ID (best-effort see note)
189
189
  name: "Client Name"
190
190
  ghl_firebase_refresh_token: FROM_STEP_7
191
191
  ghl_user_id: FROM_STEP_6
192
192
  test_location_id: CLIENT_LOCATION_ID (optional — runs a live check that it works)
193
193
  ```
194
+ > **`companyId` is best-effort.** GHL's agency-URL company ID is a *different* identifier than the internal one used for sub-accounts. The tool decodes the company ID the Firebase token itself authenticates as and stores the entry under that, so you don't have to track down the exact internal ID — pass whatever you have (e.g. from `register_location`'s output) and it self-corrects.
194
195
 
195
196
  4. **Switch and build:** `switch_location CLIENT_LOCATION_ID` swaps both the API key *and* the Firebase auth automatically. The workflow builder now operates in the client's account. Switching back to your own location restores your home Firebase.
196
197
 
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.27.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
 
@@ -394,7 +404,7 @@ var TokenRegistryDataSchema = import_zod2.z.object({
394
404
  // process operate the workflow builder across multiple clients' GHL accounts.
395
405
  firebaseByCompany: import_zod2.z.record(CompanyFirebaseSchema).optional()
396
406
  });
397
- var TokenRegistry = class {
407
+ var TokenRegistry = class _TokenRegistry {
398
408
  data;
399
409
  filePath;
400
410
  constructor(filePath) {
@@ -568,26 +578,36 @@ var TokenRegistry = class {
568
578
  this.save();
569
579
  }
570
580
  }
581
+ /**
582
+ * Normalize a company id used as a firebaseByCompany key. Trims whitespace so
583
+ * a stray copy/paste space can't fork one company into two registry entries
584
+ * (one stored, a different one looked up). Keys are otherwise case-sensitive
585
+ * because GHL company ids are case-sensitive Mongo-style ids.
586
+ */
587
+ static normalizeCompanyId(companyId) {
588
+ return companyId.trim();
589
+ }
571
590
  /**
572
591
  * Get a specific company's Firebase config (multi-tenant routing).
573
592
  */
574
593
  getCompanyFirebase(companyId) {
575
- return this.data.firebaseByCompany?.[companyId];
594
+ return this.data.firebaseByCompany?.[_TokenRegistry.normalizeCompanyId(companyId)];
576
595
  }
577
596
  /**
578
597
  * Store (or replace) a company's Firebase config.
579
598
  */
580
599
  setCompanyFirebase(companyId, config3) {
581
600
  if (!this.data.firebaseByCompany) this.data.firebaseByCompany = {};
582
- this.data.firebaseByCompany[companyId] = config3;
601
+ this.data.firebaseByCompany[_TokenRegistry.normalizeCompanyId(companyId)] = config3;
583
602
  this.save();
584
603
  }
585
604
  /**
586
605
  * Remove a company's Firebase config. Returns true if one existed.
587
606
  */
588
607
  removeCompanyFirebase(companyId) {
589
- if (this.data.firebaseByCompany?.[companyId]) {
590
- delete this.data.firebaseByCompany[companyId];
608
+ const key = _TokenRegistry.normalizeCompanyId(companyId);
609
+ if (this.data.firebaseByCompany?.[key]) {
610
+ delete this.data.firebaseByCompany[key];
591
611
  this.save();
592
612
  return true;
593
613
  }
@@ -609,7 +629,7 @@ var TokenRegistry = class {
609
629
  * company is the active one). No-op if the company isn't registered.
610
630
  */
611
631
  updateCompanyFirebaseRefreshToken(companyId, newToken) {
612
- const cfg = this.data.firebaseByCompany?.[companyId];
632
+ const cfg = this.data.firebaseByCompany?.[_TokenRegistry.normalizeCompanyId(companyId)];
613
633
  if (cfg) {
614
634
  cfg.refreshToken = newToken;
615
635
  this.save();
@@ -629,6 +649,24 @@ var path3 = __toESM(require("path"));
629
649
  var dotenv = __toESM(require("dotenv"));
630
650
  var import_zod4 = require("zod");
631
651
 
652
+ // src/firebase-claims.ts
653
+ function decodeFirebaseClaims(idToken) {
654
+ try {
655
+ const seg = idToken.split(".")[1];
656
+ if (!seg) return {};
657
+ const json = Buffer.from(seg.replace(/-/g, "+").replace(/_/g, "/"), "base64").toString("utf-8");
658
+ const claims = JSON.parse(json);
659
+ return {
660
+ companyId: typeof claims.company_id === "string" ? claims.company_id : void 0,
661
+ userId: typeof claims.user_id === "string" ? claims.user_id : void 0,
662
+ type: typeof claims.type === "string" ? claims.type : void 0,
663
+ role: typeof claims.role === "string" ? claims.role : void 0
664
+ };
665
+ } catch {
666
+ return {};
667
+ }
668
+ }
669
+
632
670
  // src/trigger-schemas.ts
633
671
  var import_zod3 = require("zod");
634
672
  var TriggerActionSchema = import_zod3.z.object({
@@ -1163,6 +1201,20 @@ var WorkflowBuilderClient = class _WorkflowBuilderClient {
1163
1201
  cachedIdToken = null;
1164
1202
  tokenExpiry = 0;
1165
1203
  tokenRefreshPromise = null;
1204
+ // The company_id claim from the most recently minted ID token. Lets
1205
+ // health_check report the company we're ACTUALLY authenticated as (vs. the
1206
+ // one we intended), and backs PIT/Firebase mismatch detection.
1207
+ tokenCompanyId;
1208
+ // The company that OWNS the location we're operating on, as resolved by
1209
+ // switch_location from /locations/{id}.companyId. This is the company the
1210
+ // Firebase token MUST authenticate as. It is distinct from currentCompanyId
1211
+ // (the Firebase auth company): when routing falls back to home auth, the two
1212
+ // diverge and that IS the binding bug. undefined for home-only installs or
1213
+ // before any switch_location call.
1214
+ intendedCompanyId;
1215
+ // Mismatch warnings already emitted (keyed `intended->token`). Append-only so
1216
+ // we warn once per distinct mismatch for the life of the process.
1217
+ mismatchWarnedFor = /* @__PURE__ */ new Set();
1166
1218
  constructor(config3) {
1167
1219
  this.firebaseApiKey = config3.firebaseApiKey;
1168
1220
  this.refreshToken = config3.refreshToken;
@@ -1204,6 +1256,27 @@ var WorkflowBuilderClient = class _WorkflowBuilderClient {
1204
1256
  getCurrentCompanyId() {
1205
1257
  return this.currentCompanyId;
1206
1258
  }
1259
+ /**
1260
+ * The company_id claim from the most recently minted ID token — the company we
1261
+ * are ACTUALLY authenticated as. undefined until the first token mint. When this
1262
+ * differs from getIntendedCompanyId() the binding is wrong (see performTokenRefresh).
1263
+ */
1264
+ getTokenCompanyId() {
1265
+ return this.tokenCompanyId;
1266
+ }
1267
+ /**
1268
+ * Record the company that owns the location we're operating on (resolved by
1269
+ * switch_location). The Firebase token MUST authenticate as this company; a
1270
+ * divergence is the binding bug. Set on every switch regardless of whether
1271
+ * Firebase routing found a matching credential, so health_check and the
1272
+ * mismatch detector can flag a silent fall-back to home auth.
1273
+ */
1274
+ setIntendedCompanyId(companyId) {
1275
+ this.intendedCompanyId = companyId;
1276
+ }
1277
+ getIntendedCompanyId() {
1278
+ return this.intendedCompanyId;
1279
+ }
1207
1280
  /**
1208
1281
  * Swap in a specific company's Firebase auth (multi-tenant). Used by
1209
1282
  * switch_location so the workflow builder authenticates against the company
@@ -1337,6 +1410,18 @@ var WorkflowBuilderClient = class _WorkflowBuilderClient {
1337
1410
  const data = FirebaseTokenSchema.parse(await response.json());
1338
1411
  this.cachedIdToken = data.id_token;
1339
1412
  this.tokenExpiry = Date.now() + 55 * 60 * 1e3;
1413
+ this.tokenCompanyId = decodeFirebaseClaims(data.id_token).companyId;
1414
+ const expectedCompanyId = this.intendedCompanyId ?? this.currentCompanyId;
1415
+ if (expectedCompanyId && this.tokenCompanyId && expectedCompanyId !== this.tokenCompanyId) {
1416
+ const warnKey = `${expectedCompanyId}->${this.tokenCompanyId}`;
1417
+ if (!this.mismatchWarnedFor.has(warnKey)) {
1418
+ this.mismatchWarnedFor.add(warnKey);
1419
+ process.stderr.write(
1420
+ `[ghl-mcp] WARNING: Firebase/company mismatch. Operating on company ${expectedCompanyId} but the Firebase token authenticates as ${this.tokenCompanyId}. Workflow-builder writes will 401 or hit the wrong account. The company Firebase is missing or registered under the wrong ID \u2014 run register_company_firebase with a token captured from that sub-account's session.
1421
+ `
1422
+ );
1423
+ }
1424
+ }
1340
1425
  if (data.refresh_token && data.refresh_token !== this.refreshToken) {
1341
1426
  this.refreshToken = data.refresh_token;
1342
1427
  this.persistRotatedToken(data.refresh_token);
@@ -2398,14 +2483,14 @@ var TeamMemberLocationConfigSchema = import_zod9.z.object({
2398
2483
  kind: import_zod9.z.string().describe("Meeting location kind. Common values: 'custom', 'zoom_conference', 'google_meet'."),
2399
2484
  position: import_zod9.z.number().int().nonnegative().describe("Position index for ordering (0-based)."),
2400
2485
  location: import_zod9.z.string().optional().describe("Display string for the meeting location (URL or address). Optional for kind='custom'.")
2401
- });
2486
+ }).strict();
2402
2487
  var CalendarTeamMemberSchema = import_zod9.z.object({
2403
2488
  userId: import_zod9.z.string().describe("GHL user ID to assign. Must exist in this sub-account."),
2404
2489
  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
2490
  isPrimary: import_zod9.z.boolean().describe("Whether this user is the primary assignee."),
2406
2491
  selected: import_zod9.z.boolean().describe("Whether this user is currently active on the calendar (true to enable)."),
2407
2492
  locationConfigurations: import_zod9.z.array(TeamMemberLocationConfigSchema).min(1).describe("At least one meeting-location config. Typical shape: [{kind:'custom', position:0, location:''}].")
2408
- });
2493
+ }).strict();
2409
2494
  function registerCalendarTools(server2, client) {
2410
2495
  safeTool(
2411
2496
  server2,
@@ -5844,25 +5929,35 @@ var FIREBASE_CAPTURE_SCRIPT = `(async () => {
5844
5929
  r.onsuccess = () => res(r.result);
5845
5930
  r.onerror = () => rej(r.error);
5846
5931
  });
5847
- const row = rows.find(r => typeof r?.fbase_key === 'string' && r.fbase_key.startsWith('firebase:authUser:AIza'));
5848
- if (!row) {
5932
+ 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);
5933
+ if (!candidates.length) {
5849
5934
  console.log('%cGHL Command: no Firebase login found.', 'color:red;font-weight:bold');
5850
5935
  console.log('Open a tab logged into your GHL account, then run this again in that tab.');
5851
5936
  return;
5852
5937
  }
5853
- const v = row.value || {};
5938
+ // Multi-account safety (v3.26.0): if the browser has more than one
5939
+ // GHL Firebase login cached, show the buyer which one we're using and
5940
+ // how to pick a different one. The first matching row is the freshest
5941
+ // login per Firebase's storage order, but it isn't always the buyer's
5942
+ // CURRENT account if they recently switched.
5943
+ if (candidates.length > 1) {
5944
+ console.log('%cGHL Command: ' + candidates.length + ' Firebase logins found in this browser:', 'color:orange;font-weight:bold');
5945
+ candidates.forEach((c, i) => {
5946
+ const v = c.value || {};
5947
+ const lastLogin = v.lastLoginAt ? new Date(Number(v.lastLoginAt)).toISOString() : '(no timestamp)';
5948
+ console.log(' [' + i + '] ' + (v.email || '(no email)') + ' \u2014 uid ' + (v.uid || '').slice(0, 8) + '\u2026 \u2014 last login ' + lastLogin);
5949
+ });
5950
+ 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.');
5951
+ }
5952
+ const v = candidates[0].value;
5854
5953
  const out = {
5855
5954
  ghl_firebase_api_key: v.apiKey,
5856
5955
  ghl_user_id: v.uid,
5857
- ghl_firebase_refresh_token: v.stsTokenManager?.refreshToken,
5956
+ ghl_firebase_refresh_token: v.stsTokenManager.refreshToken,
5858
5957
  };
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
5958
  const json = JSON.stringify(out, null, 2);
5865
5959
  console.log('%cGHL Command: paste this into setup_ghl_mcp (firebase_paste field):', 'color:green;font-weight:bold');
5960
+ if (v.email) console.log('Account: ' + v.email);
5866
5961
  console.log(json);
5867
5962
  try {
5868
5963
  await navigator.clipboard.writeText(json);
@@ -5914,8 +6009,9 @@ function deviceFingerprint() {
5914
6009
  return crypto2.createHash("sha256").update(raw).digest("hex").slice(0, 16);
5915
6010
  }
5916
6011
  async function validateLicense(email, licenseKey) {
6012
+ let res;
5917
6013
  try {
5918
- const res = await fetch(LICENSE_API, {
6014
+ res = await fetch(LICENSE_API, {
5919
6015
  method: "POST",
5920
6016
  headers: { "Content-Type": "application/json" },
5921
6017
  body: JSON.stringify({
@@ -5925,19 +6021,28 @@ async function validateLicense(email, licenseKey) {
5925
6021
  }),
5926
6022
  signal: AbortSignal.timeout(1e4)
5927
6023
  });
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
6024
  } catch (err) {
5938
6025
  const msg = err instanceof Error ? err.message : String(err);
5939
- return { ok: false, error: `Could not reach license server: ${msg}` };
6026
+ return { ok: false, error: `Could not reach license server: ${msg}`, reason: "unreachable" };
6027
+ }
6028
+ if (res.status >= 500) {
6029
+ return { ok: false, error: `License server returned HTTP ${res.status}`, reason: "unreachable" };
6030
+ }
6031
+ const data = await res.json().catch(() => ({}));
6032
+ if (res.ok && data.valid) {
6033
+ return {
6034
+ ok: true,
6035
+ installs: `${data.installs_used}/${data.installs_max}`,
6036
+ signedAttestation: typeof data.signed_attestation === "string" ? data.signed_attestation : void 0
6037
+ };
5940
6038
  }
6039
+ const errorText = String(data.error || data.message || "").toLowerCase();
6040
+ const looksCancelled = res.status === 403 || errorText.includes("cancel") || errorText.includes("install limit");
6041
+ return {
6042
+ ok: false,
6043
+ error: data.error || data.message || `License validation failed (HTTP ${res.status})`,
6044
+ reason: looksCancelled ? "cancelled" : "unauthorized"
6045
+ };
5941
6046
  }
5942
6047
  async function validateGhl(apiKey2, locationId2) {
5943
6048
  try {
@@ -5969,7 +6074,9 @@ async function validateFirebase(firebaseKey, refreshToken) {
5969
6074
  signal: AbortSignal.timeout(1e4)
5970
6075
  });
5971
6076
  if (!res.ok) return { ok: false, error: "Firebase credentials rejected. Re-extract them from your GHL browser tab." };
5972
- return { ok: true };
6077
+ const data = await res.json().catch(() => ({}));
6078
+ const claims = data.id_token ? decodeFirebaseClaims(data.id_token) : {};
6079
+ return { ok: true, companyId: claims.companyId, userId: claims.userId };
5973
6080
  } catch (err) {
5974
6081
  const msg = err instanceof Error ? err.message : String(err);
5975
6082
  return { ok: false, error: `Could not reach Firebase: ${msg}` };
@@ -6164,13 +6271,13 @@ DevTools steps: https://elitedcs.com/ghl-mcp-firebase`
6164
6271
  text: [
6165
6272
  "Workflow Builder enabled!",
6166
6273
  "",
6167
- "Firebase credentials verified and saved.",
6274
+ "Firebase credentials verified and saved to credentials.json.",
6168
6275
  "",
6169
- "**Restart Claude (quit fully and reopen) to load the workflow builder + funnel builder + pipeline builder + form builder + workflow cloner tools (212 total).**",
6276
+ "**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
6277
  "",
6171
- 'After restart, try: "List my workflows in full detail" or "Validate workflow <id>".',
6278
+ 'After restart, all 212 tools load. Try: "List my workflows in full detail" or "Validate workflow <id>".',
6172
6279
  "",
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."
6280
+ "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
6281
  ].join("\n")
6175
6282
  }]
6176
6283
  };
@@ -6276,12 +6383,18 @@ Firebase: using ${cfg.name || `company ${companyId}`} credentials \u2014 workflo
6276
6383
  }
6277
6384
  builderClient.resetToHomeFirebase();
6278
6385
  const homeCompanyId = builderClient.getCurrentCompanyId();
6279
- if (homeCompanyId === void 0) {
6280
- return "\nFirebase: using home credentials. Home company id isn't configured (set GHL_COMPANY_ID), so if this is a client account its workflow-builder tools will 401 until you run register_company_firebase.";
6281
- }
6282
6386
  if (homeCompanyId === companyId) {
6283
6387
  return "\nFirebase: home company \u2014 workflow builder enabled.";
6284
6388
  }
6389
+ const registeredCompanies = registry2?.listCompanyFirebases?.() ?? [];
6390
+ if (registeredCompanies.length > 0) {
6391
+ const keys = registeredCompanies.map((c) => c.companyId).join(", ");
6392
+ return `
6393
+ Firebase: this sub-account's company is ${companyId}, but your registered company Firebase key(s) are [${keys}] \u2014 none match, so workflow-builder tools will 401 here. This is usually a company-ID mismatch: GHL's agency-URL ID differs from the internal company_id used for sub-accounts. Re-run register_company_firebase with a token captured from THIS sub-account's session (v3.27.0+ auto-stores it under the correct internal ID), or check list_registered_locations.`;
6394
+ }
6395
+ if (homeCompanyId === void 0) {
6396
+ return "\nFirebase: using home credentials. Home company id isn't configured (set GHL_COMPANY_ID), so if this is a client account its workflow-builder tools will 401 until you run register_company_firebase.";
6397
+ }
6285
6398
  return `
6286
6399
  Firebase: NOT configured for company ${companyId}. Workflow builder, funnels, forms, pipelines, smart lists, reputation, email campaigns and memberships will fail here (401) until you run register_company_firebase for this company. Public-API tools (contacts, opportunities, calendars, etc.) still work.`;
6287
6400
  }
@@ -6363,6 +6476,9 @@ Token registry: ${registeredCount} location(s) registered${versionLine}`
6363
6476
  if (targetCompanyId && registry2?.getToken(locationId2)) {
6364
6477
  registry2.setLocationCompanyId(locationId2, targetCompanyId);
6365
6478
  }
6479
+ if (builderClient) {
6480
+ builderClient.setIntendedCompanyId(targetCompanyId);
6481
+ }
6366
6482
  const firebaseStatus = routeFirebaseForCompany(builderClient, registry2, targetCompanyId);
6367
6483
  const keyStatus = keySwapped ? `API key swapped: ${previousKeyPrefix} \u2192 ${client.getApiKeyPrefix()}` : `API key unchanged (${client.getApiKeyPrefix()})${!registry2?.getToken(locationId2) ? " \u2014 consider using register_location to add this location's key" : ""}`;
6368
6484
  return {
@@ -6486,9 +6602,9 @@ The API key could not access location ${locationId2}. Make sure:
6486
6602
  );
6487
6603
  server2.tool(
6488
6604
  "register_company_firebase",
6489
- "Register a GHL company's Firebase credentials so the workflow builder and all Firebase-gated tools work when you switch into THAT company's sub-accounts. Firebase refresh tokens are company-scoped, so managing a client's GHL (e.g. an account where you're an admin user) requires that company's own token. Capture the values from a browser session logged into the client's account and register them keyed by the client's companyId. After this, switch_location to any of that company's locations authenticates the workflow builder correctly. DevTools capture steps: elitedcs.com/ghl-mcp-firebase.",
6605
+ "Register a GHL company's Firebase credentials so the workflow builder and all Firebase-gated tools work when you switch into THAT company's sub-accounts. Firebase refresh tokens are company-scoped, so managing a client's GHL (e.g. an account where you're an admin user) requires that company's own token. Capture the values from a browser session logged into the client's account. The tool stores them under the company ID the Firebase token itself authenticates as (decoded from the token), so you do NOT need to hunt down the exact internal company ID \u2014 pass whatever ID you have and it self-corrects. After this, switch_location to any of that company's locations authenticates the workflow builder correctly. DevTools capture steps: elitedcs.com/ghl-mcp-firebase.",
6490
6606
  {
6491
- companyId: import_zod38.z.string().describe("The GHL company/agency ID that owns the client's sub-accounts. Surfaced in switch_location and register_location output, or the GHL URL when viewing the agency."),
6607
+ companyId: import_zod38.z.string().describe("A GHL company/agency ID for this client (from switch_location/register_location output, or the GHL agency URL). Best-effort only: the tool re-keys the entry to the company ID the Firebase token actually authenticates as, which can differ from the ID shown in the agency URL. Just pass what you have."),
6492
6608
  name: import_zod38.z.string().describe("Friendly name for this client/company (e.g. 'Nathan \u2014 Acme Health')."),
6493
6609
  ghl_firebase_refresh_token: import_zod38.z.string().min(10).describe("Firebase refresh token captured from a browser session logged into THIS company's GHL. value.stsTokenManager.refreshToken in the firebase:authUser IndexedDB row."),
6494
6610
  ghl_user_id: import_zod38.z.string().min(5).describe("Firebase User ID (uid) from the same session. value.uid in the firebase:authUser row."),
@@ -6511,6 +6627,7 @@ The API key could not access location ${locationId2}. Make sure:
6511
6627
  }
6512
6628
  const refreshToken = args.ghl_firebase_refresh_token.trim();
6513
6629
  const userId = args.ghl_user_id.trim();
6630
+ const typedCompanyId = args.companyId.trim();
6514
6631
  const fb = await validateFirebase(apiKey2, refreshToken);
6515
6632
  if (!fb.ok) {
6516
6633
  return {
@@ -6520,7 +6637,14 @@ Capture fresh values from a browser session logged into THIS company's GHL. Step
6520
6637
  isError: true
6521
6638
  };
6522
6639
  }
6523
- registry2.setCompanyFirebase(args.companyId, { apiKey: apiKey2, refreshToken, userId, name: args.name });
6640
+ const canonicalCompanyId = fb.companyId || typedCompanyId;
6641
+ let keyNote = "";
6642
+ if (fb.companyId && fb.companyId !== typedCompanyId) {
6643
+ keyNote = `
6644
+
6645
+ Note: stored under the company ID the Firebase token actually uses (${fb.companyId}), not the value you entered (${typedCompanyId}). The ID in the GHL agency URL differs from the one GHL uses internally for sub-accounts; switch_location matches on the internal one, so this is what makes it work.`;
6646
+ }
6647
+ registry2.setCompanyFirebase(canonicalCompanyId, { apiKey: apiKey2, refreshToken, userId, name: args.name });
6524
6648
  let testLine = "";
6525
6649
  if (args.test_location_id) {
6526
6650
  const token = registry2.getToken(args.test_location_id);
@@ -6529,33 +6653,57 @@ Capture fresh values from a browser session logged into THIS company's GHL. Step
6529
6653
 
6530
6654
  Skipped end-to-end test: location ${args.test_location_id} isn't registered. Run register_location for it, then switch_location to confirm.`;
6531
6655
  } else {
6532
- const probe = new WorkflowBuilderClient({
6533
- firebaseApiKey: apiKey2,
6534
- refreshToken,
6535
- apiKey: token.apiKey,
6536
- locationId: args.test_location_id,
6537
- userId,
6538
- companyId: args.companyId,
6539
- registry: null
6540
- });
6541
- try {
6542
- await probe.listWorkflows(1, 0);
6656
+ let resolvedCompanyId = token.companyId;
6657
+ let lookupFailed = false;
6658
+ if (!resolvedCompanyId) {
6659
+ try {
6660
+ const locClient = new GHLClient({ apiKey: token.apiKey, locationId: args.test_location_id });
6661
+ const locResp = await locClient.get(`/locations/${args.test_location_id}`);
6662
+ resolvedCompanyId = locResp?.location?.companyId ?? locResp?.companyId;
6663
+ } catch {
6664
+ lookupFailed = true;
6665
+ }
6666
+ }
6667
+ if (!resolvedCompanyId) {
6543
6668
  testLine = `
6544
6669
 
6545
- Verified: the workflow-builder API responded for "${token.name}" (${args.test_location_id}). These credentials work for this company.`;
6546
- } catch (error) {
6547
- const message = error instanceof Error ? error.message : String(error);
6548
- testLine = `
6670
+ Registered under ${canonicalCompanyId}, but couldn't confirm the test location's company${lookupFailed ? " (its PIT may be missing or invalid \u2014 re-run register_location for it)" : ""}. switch_location still resolves the company live at switch time, so this is non-blocking \u2014 switch into the account to confirm.`;
6671
+ } else {
6672
+ const foundViaRealPath = registry2.getCompanyFirebase(resolvedCompanyId);
6673
+ if (!foundViaRealPath) {
6674
+ testLine = `
6675
+
6676
+ WARNING: saved under ${canonicalCompanyId}, but switch_location resolves ${args.test_location_id} to company ${resolvedCompanyId ?? "(unknown)"} \u2014 these do not match, so the workflow builder would fall back to home and 401. Re-capture the Firebase token from a session inside THIS sub-account's company, or pass a test_location_id that belongs to the same company as the token.`;
6677
+ } else {
6678
+ const probe = new WorkflowBuilderClient({
6679
+ firebaseApiKey: foundViaRealPath.apiKey,
6680
+ refreshToken: foundViaRealPath.refreshToken,
6681
+ apiKey: token.apiKey,
6682
+ locationId: args.test_location_id,
6683
+ userId: foundViaRealPath.userId,
6684
+ companyId: resolvedCompanyId,
6685
+ registry: null
6686
+ });
6687
+ try {
6688
+ await probe.listWorkflows(1, 0);
6689
+ testLine = `
6690
+
6691
+ Verified end-to-end via the real lookup path: switch_location resolves ${args.test_location_id} \u2192 company ${resolvedCompanyId}, the registry has matching Firebase, and the workflow-builder API responded for "${token.name}". This will work.`;
6692
+ } catch (error) {
6693
+ const message = error instanceof Error ? error.message : String(error);
6694
+ testLine = `
6549
6695
 
6550
- WARNING: saved, but the end-to-end test FAILED for ${args.test_location_id}:
6696
+ WARNING: key resolution matched but the workflow-builder API call FAILED for ${args.test_location_id}:
6551
6697
  ${message}
6552
6698
 
6553
- Likely causes: the refresh token was captured from a DIFFERENT company's session, or that location's PIT key lacks access. Re-capture from a session inside THIS company's GHL.`;
6699
+ Likely cause: that location's PIT key (registered via register_location) belongs to a different company than the Firebase token, or the token rotated. Re-capture from a session inside THIS company's GHL.`;
6700
+ }
6701
+ }
6554
6702
  }
6555
6703
  }
6556
6704
  }
6557
6705
  return {
6558
- content: [{ type: "text", text: `Registered Firebase for ${args.name} (company ${args.companyId}).${testLine}
6706
+ content: [{ type: "text", text: `Registered Firebase for ${args.name} (company ${canonicalCompanyId}).${keyNote}${testLine}
6559
6707
 
6560
6708
  Now run switch_location to any of this company's sub-accounts \u2014 the workflow builder will authenticate against it automatically.` }]
6561
6709
  };
@@ -8354,10 +8502,19 @@ function registerDiagnosticTools(server2, installedVersion, client, builderClien
8354
8502
  return { name: "Firebase auth (workflow builder)", status: "skip", detail: "Not configured. The 49 Firebase-gated tools need Firebase credentials. The other 163 tools work fine without. To add it: run enable_workflow_builder with the three Firebase values from your GHL browser session (see elitedcs.com/ghl-mcp-firebase for DevTools steps). Do NOT put Firebase values as env vars in your Claude Desktop config \u2014 that path is unreliable and is the usual reason this still shows skip after a restart. enable_workflow_builder saves and verifies them for you." };
8355
8503
  }
8356
8504
  const result = await builderClient.checkAuth();
8357
- const activeCompany = builderClient.getCurrentCompanyId();
8358
- const companyNote = activeCompany ? ` Active company: ${activeCompany}.` : "";
8505
+ const tokenCompany = builderClient.getTokenCompanyId();
8506
+ const intendedCompany = builderClient.getIntendedCompanyId() ?? builderClient.getCurrentCompanyId();
8507
+ const companyNote = intendedCompany ? ` Active location's company: ${intendedCompany}.` : "";
8508
+ if (result.ok && intendedCompany && tokenCompany && intendedCompany !== tokenCompany) {
8509
+ return {
8510
+ name: "Firebase auth (workflow builder)",
8511
+ status: "fail",
8512
+ detail: `Company mismatch: the active location belongs to company ${intendedCompany}, but the Firebase token authenticates as ${tokenCompany}. Workflow-builder writes will 401 or hit the wrong account. This company's Firebase is missing or registered under the wrong ID. Run register_company_firebase with a token captured from that sub-account's GHL session (v3.27.0+ stores it under the correct internal ID automatically).`
8513
+ };
8514
+ }
8359
8515
  if (result.ok) {
8360
- return { name: "Firebase auth (workflow builder)", status: "pass", detail: `ID token refresh succeeded. Workflow builder tools are usable.${companyNote}` };
8516
+ const idNote = tokenCompany ? ` Authenticated as company ${tokenCompany}.` : "";
8517
+ return { name: "Firebase auth (workflow builder)", status: "pass", detail: `ID token refresh succeeded. Workflow builder tools are usable.${companyNote}${idNote}` };
8361
8518
  }
8362
8519
  return { name: "Firebase auth (workflow builder)", status: "fail", detail: `Token refresh failed: ${result.error}.${companyNote} If you're in a client's account, register its Firebase with register_company_firebase. Otherwise re-capture your Firebase values from GHL DevTools and re-run enable_workflow_builder.` };
8363
8520
  })();
@@ -8544,12 +8701,6 @@ async function verifyAttestation(token, bindings, now = /* @__PURE__ */ new Date
8544
8701
  }
8545
8702
  return { ok: false, reason: "expired" };
8546
8703
  }
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
8704
 
8554
8705
  // src/tools/meta.ts
8555
8706
  function registerMetaTools(server2, installedVersion) {
@@ -8633,15 +8784,16 @@ var server = new import_mcp.McpServer({
8633
8784
  });
8634
8785
  registerMetaTools(server, pkg.version);
8635
8786
  var inBootstrapMode = true;
8787
+ var TRANSITIONAL_ATTESTATION_DEADLINE = Date.parse("2026-06-15T00:00:00.000Z");
8636
8788
  async function renewAttestation(creds) {
8637
- if (!creds.email || !creds.license_key) return false;
8789
+ if (!creds.email || !creds.license_key) return "unauthorized";
8790
+ let lic;
8638
8791
  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
- }
8792
+ lic = await validateLicense(creds.email, creds.license_key);
8793
+ } catch {
8794
+ return "unreachable";
8795
+ }
8796
+ if (lic.ok) {
8645
8797
  try {
8646
8798
  writeCredentials({
8647
8799
  ...creds,
@@ -8651,12 +8803,23 @@ async function renewAttestation(creds) {
8651
8803
  } catch {
8652
8804
  }
8653
8805
  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");
8806
+ 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");
8807
+ return "renewed-unsigned";
8655
8808
  }
8656
- return true;
8657
- } catch {
8658
- return false;
8809
+ return "renewed";
8810
+ }
8811
+ if (lic.reason === "cancelled" || lic.reason === "unauthorized") {
8812
+ try {
8813
+ writeCredentials({ ...creds, signed_attestation: void 0 });
8814
+ process.stderr.write(`[ghl-mcp] Server rejected license (${lic.reason}). Dropping to bootstrap mode.
8815
+ `);
8816
+ } catch {
8817
+ }
8818
+ return lic.reason;
8659
8819
  }
8820
+ process.stderr.write(`[ghl-mcp] License re-validate unreachable: ${lic.error}
8821
+ `);
8822
+ return "unreachable";
8660
8823
  }
8661
8824
  function renewAttestationInBackground(creds) {
8662
8825
  return renewAttestation(creds).then(() => void 0, () => void 0);
@@ -8672,14 +8835,15 @@ async function resolveAccessAndRegister() {
8672
8835
  if (verify.ok) {
8673
8836
  licenseVerified = true;
8674
8837
  attestationStatus = verify.reason;
8675
- if (verify.reason === "in-grace" || shouldRenew(verify.payload)) {
8676
- void renewAttestationInBackground(fileCreds);
8677
- }
8838
+ void renewAttestationInBackground(fileCreds);
8678
8839
  } else {
8679
8840
  process.stderr.write(`[ghl-mcp] Stored attestation rejected: ${verify.reason}. Re-validating online.
8680
8841
  `);
8681
8842
  const renewed = await renewAttestation(fileCreds);
8682
- if (renewed) {
8843
+ if (renewed === "renewed") {
8844
+ licenseVerified = true;
8845
+ attestationStatus = "renewed-after-tamper";
8846
+ } else if (renewed === "renewed-unsigned" && Date.now() < TRANSITIONAL_ATTESTATION_DEADLINE) {
8683
8847
  licenseVerified = true;
8684
8848
  attestationStatus = "renewed-after-tamper";
8685
8849
  } else {
@@ -8689,12 +8853,22 @@ async function resolveAccessAndRegister() {
8689
8853
  } else if (fileCreds?.email && fileCreds?.license_key) {
8690
8854
  process.stderr.write("[ghl-mcp] Upgrading legacy credentials.json to signed attestation.\n");
8691
8855
  const renewed = await renewAttestation(fileCreds);
8692
- if (renewed) {
8856
+ if (renewed === "renewed") {
8857
+ licenseVerified = true;
8858
+ attestationStatus = "renewed-from-legacy";
8859
+ } else if (renewed === "renewed-unsigned" && Date.now() < TRANSITIONAL_ATTESTATION_DEADLINE) {
8693
8860
  licenseVerified = true;
8694
8861
  attestationStatus = "renewed-from-legacy";
8695
8862
  } else {
8696
8863
  attestationStatus = "needs-reset";
8697
- process.stderr.write("[ghl-mcp] Could not re-validate. Run setup_ghl_mcp again.\n");
8864
+ if (renewed === "cancelled" || renewed === "unauthorized") {
8865
+ process.stderr.write(`[ghl-mcp] Server says license is ${renewed}. Bootstrap mode.
8866
+ `);
8867
+ } else if (renewed === "renewed-unsigned") {
8868
+ process.stderr.write("[ghl-mcp] Server is in unsigned-attestation mode past the transition deadline. Bootstrap mode.\n");
8869
+ } else {
8870
+ process.stderr.write("[ghl-mcp] Could not re-validate. Run setup_ghl_mcp again.\n");
8871
+ }
8698
8872
  }
8699
8873
  }
8700
8874
  if (!licenseVerified && apiKey && locationId) {
@@ -8702,7 +8876,8 @@ async function resolveAccessAndRegister() {
8702
8876
  const licKey = (process.env.GHL_LICENSE_KEY || fileCreds?.license_key)?.trim();
8703
8877
  if (licEmail && licKey) {
8704
8878
  const lic = await validateLicense(licEmail, licKey);
8705
- if (lic.ok) {
8879
+ const acceptUnsigned = !lic.ok ? false : lic.signedAttestation || Date.now() < TRANSITIONAL_ATTESTATION_DEADLINE;
8880
+ if (lic.ok && acceptUnsigned) {
8706
8881
  licenseVerified = true;
8707
8882
  attestationStatus = "renewed";
8708
8883
  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.27.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",