@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 +48 -0
- package/README.md +2 -1
- package/dist/index.js +263 -88
- package/package.json +1 -1
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 (
|
|
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.
|
|
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
|
-
|
|
363
|
-
|
|
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.
|
|
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
|
-
|
|
590
|
-
|
|
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
|
|
5848
|
-
if (!
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
"**
|
|
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,
|
|
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,
|
|
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
|
|
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("
|
|
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
|
-
|
|
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
|
-
|
|
6533
|
-
|
|
6534
|
-
|
|
6535
|
-
|
|
6536
|
-
|
|
6537
|
-
|
|
6538
|
-
|
|
6539
|
-
|
|
6540
|
-
|
|
6541
|
-
|
|
6542
|
-
|
|
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
|
-
|
|
6546
|
-
}
|
|
6547
|
-
const
|
|
6548
|
-
|
|
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:
|
|
6696
|
+
WARNING: key resolution matched but the workflow-builder API call FAILED for ${args.test_location_id}:
|
|
6551
6697
|
${message}
|
|
6552
6698
|
|
|
6553
|
-
Likely
|
|
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 ${
|
|
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
|
|
8358
|
-
const
|
|
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
|
-
|
|
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
|
|
8789
|
+
if (!creds.email || !creds.license_key) return "unauthorized";
|
|
8790
|
+
let lic;
|
|
8638
8791
|
try {
|
|
8639
|
-
|
|
8640
|
-
|
|
8641
|
-
|
|
8642
|
-
|
|
8643
|
-
|
|
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.
|
|
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
|
|
8657
|
-
}
|
|
8658
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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",
|