@elitedcs/ghl-mcp 3.24.0 → 3.26.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +47 -0
- package/dist/index.js +285 -66
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,52 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 3.26.0 — Codex adversarial audit fixes
|
|
4
|
+
|
|
5
|
+
**Tool count unchanged (213). No new features — closes a set of silent bugs Codex flagged in a third-party adversarial review of the v3.21.0–v3.25.0 release arc.**
|
|
6
|
+
|
|
7
|
+
Now that paid customers run this code, we ran the same Codex-rescue review pattern we use on the PN tracker app. Codex turned up 10 issues; v3.26.0 ships the ones with concrete fixes. Deferred items (server-outage recovery, rate-limiting at the edge) are documented in the project memory.
|
|
8
|
+
|
|
9
|
+
**Security tightening:**
|
|
10
|
+
|
|
11
|
+
- **Cancelled-subscription detection.** Before: a cancelled license kept running for up to 44 days (30-day attestation TTL + 14-day grace) because the MCP never reacted to a server "cancelled" response — it just kept using the cached attestation until expiry. Now: `validateLicense` distinguishes `cancelled` / `unauthorized` / `unreachable`, and on `cancelled`/`unauthorized` the cached attestation is wiped from `credentials.json` so the next restart drops to bootstrap. The buyer's current session keeps running (we don't kill tools mid-call); the gate latches on next launch.
|
|
12
|
+
- **Background renewal on every startup.** Previously only triggered when the attestation was nearing expiry. Now every startup fires a fire-and-forget renewal so cancellations propagate within one restart instead of up-to-44-days.
|
|
13
|
+
- **Fail-closed when the server stops issuing signed attestations.** Before: if `ATTESTATION_PRIVATE_KEY` ever dropped off Cloudflare (env reset, accidental rotation, partial deploy), validate-license would still return `valid:true` without a `signed_attestation` and the MCP would silently fall back to legacy trust — re-opening the credentials.json bypass v3.24.0 closed. Now: after the `TRANSITIONAL_ATTESTATION_DEADLINE` (2026-06-15), an unsigned `valid:true` response is treated as bootstrap; before the deadline we still accept it for migration but log a warning with the cutoff date.
|
|
14
|
+
|
|
15
|
+
**Correctness:**
|
|
16
|
+
|
|
17
|
+
- **Atomic `credentials.json` writes.** Replaced the bare `writeFileSync` with write-to-temp-then-rename plus process-unique temp suffixes. Mid-write crashes can no longer corrupt the file, and concurrent writers (e.g. `enable_workflow_builder` racing a Firebase token rotation) can't leave it half-formed. New tests in `credentials-store.test.ts` cover both the happy path and a 20-way concurrent-write fan-out.
|
|
18
|
+
- **`update_calendar` schema is now `.strict()`.** Unknown fields on `teamMembers` or `locationConfigurations` (e.g. `userid` instead of `userId`, or a new GHL field we haven't captured) now error at the MCP boundary with a useful 422 instead of being silently dropped before the GHL PUT. Buyers stop wondering why a field they passed didn't save.
|
|
19
|
+
|
|
20
|
+
**Observability:**
|
|
21
|
+
|
|
22
|
+
- **`pipeline_skip` telemetry event.** When `validate-license` can't advance a buyer's pipeline opportunity (no opportunity exists, or all opportunities are closed), we now emit a distinct `[telemetry] pipeline_skip` line with the reason. Lets us surface "buyer's opportunity was accidentally closed" cases for manual reopen instead of silently failing forever.
|
|
23
|
+
- **Telemetry key-name redaction.** The `recordEvent` helper now scrubs any `extra` key whose name matches `/email|license|key|token|secret|refresh|password|pit/i` before logging. Belt-and-suspenders against a future caller accidentally passing a secret as a metric tag.
|
|
24
|
+
|
|
25
|
+
**UX:**
|
|
26
|
+
|
|
27
|
+
- **`enable_workflow_builder` restart messaging.** Now explicit that workflow-builder tools called BEFORE the restart will keep using the old Firebase auth and 401 — even though the tool reported success. Removes the "I ran the tool, why doesn't it work" confusion.
|
|
28
|
+
- **Welcome email "fifth field" not "sixth."** Off-by-one in the advanced-shortcut paragraph corrected.
|
|
29
|
+
- **Firebase capture script handles multiple GHL logins.** Previously the script returned the first matching `firebase:authUser:` row, which could be the wrong account if the buyer had multiple GHL logins in the same browser. Now it enumerates ALL candidates, prints each with email + uid + last-login timestamp, picks the first one (still freshest by Firebase's storage order), and tells the buyer how to re-run against a different account.
|
|
30
|
+
|
|
31
|
+
## 3.25.0 — One-paste Firebase capture
|
|
32
|
+
|
|
33
|
+
**New bootstrap-and-normal-mode tool. Tool count goes from 212 to 213 (the new tool counts whether you're set up or not).**
|
|
34
|
+
|
|
35
|
+
The worst-pain step of GHL Command setup has been the three separate IndexedDB lookups buyers had to do for Firebase credentials. v3.0.x tried bookmarklets; per memory that failed because buyers couldn't drag them onto their bookmarks bar. v3.25.0 ships the working version: a tool that hands the buyer a 25-line Chrome console script. One copy, one paste in DevTools, one Enter — the script extracts all three Firebase fields from IndexedDB and copies the result to the clipboard as a JSON object. The buyer pastes the JSON back into `setup_ghl_mcp` (or `enable_workflow_builder`) via a new `firebase_paste` parameter. Three separate captures → one paste.
|
|
36
|
+
|
|
37
|
+
**New tool: `auto_capture_firebase_script`**. Available in bootstrap mode AND normal mode (the latter so existing buyers can re-grab a fresh token when their refresh rotates). Takes no arguments; returns the script plus a numbered walkthrough.
|
|
38
|
+
|
|
39
|
+
**Updated tools:**
|
|
40
|
+
|
|
41
|
+
- `setup_ghl_mcp` accepts a new optional `firebase_paste` parameter. When provided, it parses the three Firebase fields out of the JSON and treats them as if you'd filled `ghl_user_id`, `ghl_firebase_api_key`, `ghl_firebase_refresh_token` manually. The three separate fields still work (manual fallback for locked-down browsers).
|
|
42
|
+
- `enable_workflow_builder` accepts the same `firebase_paste` parameter, again with the three separate fields as fallback.
|
|
43
|
+
|
|
44
|
+
**Parser is forgiving.** Strips markdown code fences (chat-copy artifact), normalizes curly quotes (email-client artifact), trims whitespace, and demands the API key actually start with `AIza` (catches misdirected pastes — e.g. pasting the PIT key here by mistake).
|
|
45
|
+
|
|
46
|
+
**Website + email also updated.** `elitedcs.com/ghl-mcp-firebase` now leads with a copy-button that grabs the script, falls back to manual capture in a collapsed details element. The Stripe-webhook welcome email points buyers at `auto_capture_firebase_script` instead of the old four-step DevTools dive.
|
|
47
|
+
|
|
48
|
+
Locked in by 19 new tests covering parse paths (clean JSON, fence-wrapped, curly-quoted, whitespace, malformed, missing fields, wrong API-key prefix) plus the script contract (self-contained IIFE, IndexedDB + clipboard usage, expected field names, not-logged-in branch).
|
|
49
|
+
|
|
3
50
|
## 3.24.0 — Signed-attestation gate closes the credentials.json bypass
|
|
4
51
|
|
|
5
52
|
**Security fix. Tool count unchanged (212 across 43 modules).**
|
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.26.0",
|
|
35
35
|
mcpName: "io.github.drjerryrelth/ghl-command",
|
|
36
36
|
description: "GoHighLevel MCP Server for Claude. 212 tools \u2014 full CRM, automation, marketing control, and the only programmatic GHL workflow builder, now multi-tenant across client accounts.",
|
|
37
37
|
main: "dist/index.js",
|
|
@@ -359,12 +359,22 @@ function foldCredentialsIntoEnv(creds, env = process.env) {
|
|
|
359
359
|
function writeCredentials(creds) {
|
|
360
360
|
ensureAppDataDir();
|
|
361
361
|
const file = credentialsPath();
|
|
362
|
-
|
|
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
|
|
|
@@ -2398,14 +2408,14 @@ var TeamMemberLocationConfigSchema = import_zod9.z.object({
|
|
|
2398
2408
|
kind: import_zod9.z.string().describe("Meeting location kind. Common values: 'custom', 'zoom_conference', 'google_meet'."),
|
|
2399
2409
|
position: import_zod9.z.number().int().nonnegative().describe("Position index for ordering (0-based)."),
|
|
2400
2410
|
location: import_zod9.z.string().optional().describe("Display string for the meeting location (URL or address). Optional for kind='custom'.")
|
|
2401
|
-
});
|
|
2411
|
+
}).strict();
|
|
2402
2412
|
var CalendarTeamMemberSchema = import_zod9.z.object({
|
|
2403
2413
|
userId: import_zod9.z.string().describe("GHL user ID to assign. Must exist in this sub-account."),
|
|
2404
2414
|
priority: import_zod9.z.union([import_zod9.z.literal(0), import_zod9.z.literal(0.5), import_zod9.z.literal(1)]).describe("Booking priority. Exactly 0, 0.5, or 1 (anything else 422s from GHL). Higher = preferred for round-robin."),
|
|
2405
2415
|
isPrimary: import_zod9.z.boolean().describe("Whether this user is the primary assignee."),
|
|
2406
2416
|
selected: import_zod9.z.boolean().describe("Whether this user is currently active on the calendar (true to enable)."),
|
|
2407
2417
|
locationConfigurations: import_zod9.z.array(TeamMemberLocationConfigSchema).min(1).describe("At least one meeting-location config. Typical shape: [{kind:'custom', position:0, location:''}].")
|
|
2408
|
-
});
|
|
2418
|
+
}).strict();
|
|
2409
2419
|
function registerCalendarTools(server2, client) {
|
|
2410
2420
|
safeTool(
|
|
2411
2421
|
server2,
|
|
@@ -5828,6 +5838,93 @@ var import_zod38 = require("zod");
|
|
|
5828
5838
|
var os2 = __toESM(require("os"));
|
|
5829
5839
|
var crypto2 = __toESM(require("crypto"));
|
|
5830
5840
|
var import_zod37 = require("zod");
|
|
5841
|
+
|
|
5842
|
+
// src/firebase-capture-script.ts
|
|
5843
|
+
var FIREBASE_CAPTURE_SCRIPT = `(async () => {
|
|
5844
|
+
try {
|
|
5845
|
+
const db = await new Promise((res, rej) => {
|
|
5846
|
+
const r = indexedDB.open('firebaseLocalStorageDb');
|
|
5847
|
+
r.onsuccess = () => res(r.result);
|
|
5848
|
+
r.onerror = () => rej(r.error);
|
|
5849
|
+
});
|
|
5850
|
+
const tx = db.transaction('firebaseLocalStorage', 'readonly');
|
|
5851
|
+
const store = tx.objectStore('firebaseLocalStorage');
|
|
5852
|
+
const rows = await new Promise((res, rej) => {
|
|
5853
|
+
const r = store.getAll();
|
|
5854
|
+
r.onsuccess = () => res(r.result);
|
|
5855
|
+
r.onerror = () => rej(r.error);
|
|
5856
|
+
});
|
|
5857
|
+
const candidates = rows.filter(r => typeof r?.fbase_key === 'string' && r.fbase_key.startsWith('firebase:authUser:AIza') && r?.value?.apiKey && r?.value?.uid && r?.value?.stsTokenManager?.refreshToken);
|
|
5858
|
+
if (!candidates.length) {
|
|
5859
|
+
console.log('%cGHL Command: no Firebase login found.', 'color:red;font-weight:bold');
|
|
5860
|
+
console.log('Open a tab logged into your GHL account, then run this again in that tab.');
|
|
5861
|
+
return;
|
|
5862
|
+
}
|
|
5863
|
+
// Multi-account safety (v3.26.0): if the browser has more than one
|
|
5864
|
+
// GHL Firebase login cached, show the buyer which one we're using and
|
|
5865
|
+
// how to pick a different one. The first matching row is the freshest
|
|
5866
|
+
// login per Firebase's storage order, but it isn't always the buyer's
|
|
5867
|
+
// CURRENT account if they recently switched.
|
|
5868
|
+
if (candidates.length > 1) {
|
|
5869
|
+
console.log('%cGHL Command: ' + candidates.length + ' Firebase logins found in this browser:', 'color:orange;font-weight:bold');
|
|
5870
|
+
candidates.forEach((c, i) => {
|
|
5871
|
+
const v = c.value || {};
|
|
5872
|
+
const lastLogin = v.lastLoginAt ? new Date(Number(v.lastLoginAt)).toISOString() : '(no timestamp)';
|
|
5873
|
+
console.log(' [' + i + '] ' + (v.email || '(no email)') + ' \u2014 uid ' + (v.uid || '').slice(0, 8) + '\u2026 \u2014 last login ' + lastLogin);
|
|
5874
|
+
});
|
|
5875
|
+
console.log('Using [0] (most recently active). If that is the wrong account, log out of the others in their GHL tabs and re-run this script.');
|
|
5876
|
+
}
|
|
5877
|
+
const v = candidates[0].value;
|
|
5878
|
+
const out = {
|
|
5879
|
+
ghl_firebase_api_key: v.apiKey,
|
|
5880
|
+
ghl_user_id: v.uid,
|
|
5881
|
+
ghl_firebase_refresh_token: v.stsTokenManager.refreshToken,
|
|
5882
|
+
};
|
|
5883
|
+
const json = JSON.stringify(out, null, 2);
|
|
5884
|
+
console.log('%cGHL Command: paste this into setup_ghl_mcp (firebase_paste field):', 'color:green;font-weight:bold');
|
|
5885
|
+
if (v.email) console.log('Account: ' + v.email);
|
|
5886
|
+
console.log(json);
|
|
5887
|
+
try {
|
|
5888
|
+
await navigator.clipboard.writeText(json);
|
|
5889
|
+
console.log('%cAlready copied to your clipboard \u2014 Cmd+V / Ctrl+V to paste.', 'color:green');
|
|
5890
|
+
} catch (e) {
|
|
5891
|
+
console.log('%cCopy it manually from above (clipboard access not granted).', 'color:orange');
|
|
5892
|
+
}
|
|
5893
|
+
} catch (err) {
|
|
5894
|
+
console.log('%cGHL Command: script error.', 'color:red;font-weight:bold');
|
|
5895
|
+
console.log(err);
|
|
5896
|
+
}
|
|
5897
|
+
})();`;
|
|
5898
|
+
function parseFirebasePaste(input) {
|
|
5899
|
+
if (typeof input !== "string") return null;
|
|
5900
|
+
let cleaned = input.trim();
|
|
5901
|
+
cleaned = cleaned.replace(/^```(?:json)?\s*/i, "").replace(/```$/i, "").trim();
|
|
5902
|
+
cleaned = cleaned.replace(/[“”]/g, '"').replace(/[‘’]/g, "'");
|
|
5903
|
+
let parsed;
|
|
5904
|
+
try {
|
|
5905
|
+
parsed = JSON.parse(cleaned);
|
|
5906
|
+
} catch {
|
|
5907
|
+
return null;
|
|
5908
|
+
}
|
|
5909
|
+
if (!parsed || typeof parsed !== "object") return null;
|
|
5910
|
+
const obj = parsed;
|
|
5911
|
+
const api = obj.ghl_firebase_api_key;
|
|
5912
|
+
const uid = obj.ghl_user_id;
|
|
5913
|
+
const refresh = obj.ghl_firebase_refresh_token;
|
|
5914
|
+
if (typeof api !== "string" || typeof uid !== "string" || typeof refresh !== "string") return null;
|
|
5915
|
+
const trimmedApi = api.trim();
|
|
5916
|
+
const trimmedUid = uid.trim();
|
|
5917
|
+
const trimmedRefresh = refresh.trim();
|
|
5918
|
+
if (!trimmedApi.startsWith("AIza")) return null;
|
|
5919
|
+
if (!trimmedUid || !trimmedRefresh) return null;
|
|
5920
|
+
return {
|
|
5921
|
+
ghl_firebase_api_key: trimmedApi,
|
|
5922
|
+
ghl_user_id: trimmedUid,
|
|
5923
|
+
ghl_firebase_refresh_token: trimmedRefresh
|
|
5924
|
+
};
|
|
5925
|
+
}
|
|
5926
|
+
|
|
5927
|
+
// src/setup-tool.ts
|
|
5831
5928
|
var LICENSE_API = "https://elitedcs.com/api/validate-license";
|
|
5832
5929
|
var CAPTURE_API = "https://elitedcs.com/api/capture-lead";
|
|
5833
5930
|
var GHL_API = "https://services.leadconnectorhq.com";
|
|
@@ -5837,8 +5934,9 @@ function deviceFingerprint() {
|
|
|
5837
5934
|
return crypto2.createHash("sha256").update(raw).digest("hex").slice(0, 16);
|
|
5838
5935
|
}
|
|
5839
5936
|
async function validateLicense(email, licenseKey) {
|
|
5937
|
+
let res;
|
|
5840
5938
|
try {
|
|
5841
|
-
|
|
5939
|
+
res = await fetch(LICENSE_API, {
|
|
5842
5940
|
method: "POST",
|
|
5843
5941
|
headers: { "Content-Type": "application/json" },
|
|
5844
5942
|
body: JSON.stringify({
|
|
@@ -5848,19 +5946,28 @@ async function validateLicense(email, licenseKey) {
|
|
|
5848
5946
|
}),
|
|
5849
5947
|
signal: AbortSignal.timeout(1e4)
|
|
5850
5948
|
});
|
|
5851
|
-
const data = await res.json().catch(() => ({}));
|
|
5852
|
-
if (res.ok && data.valid) {
|
|
5853
|
-
return {
|
|
5854
|
-
ok: true,
|
|
5855
|
-
installs: `${data.installs_used}/${data.installs_max}`,
|
|
5856
|
-
signedAttestation: typeof data.signed_attestation === "string" ? data.signed_attestation : void 0
|
|
5857
|
-
};
|
|
5858
|
-
}
|
|
5859
|
-
return { ok: false, error: data.error || data.message || `License validation failed (HTTP ${res.status})` };
|
|
5860
5949
|
} catch (err) {
|
|
5861
5950
|
const msg = err instanceof Error ? err.message : String(err);
|
|
5862
|
-
return { ok: false, error: `Could not reach license server: ${msg}
|
|
5951
|
+
return { ok: false, error: `Could not reach license server: ${msg}`, reason: "unreachable" };
|
|
5952
|
+
}
|
|
5953
|
+
if (res.status >= 500) {
|
|
5954
|
+
return { ok: false, error: `License server returned HTTP ${res.status}`, reason: "unreachable" };
|
|
5863
5955
|
}
|
|
5956
|
+
const data = await res.json().catch(() => ({}));
|
|
5957
|
+
if (res.ok && data.valid) {
|
|
5958
|
+
return {
|
|
5959
|
+
ok: true,
|
|
5960
|
+
installs: `${data.installs_used}/${data.installs_max}`,
|
|
5961
|
+
signedAttestation: typeof data.signed_attestation === "string" ? data.signed_attestation : void 0
|
|
5962
|
+
};
|
|
5963
|
+
}
|
|
5964
|
+
const errorText = String(data.error || data.message || "").toLowerCase();
|
|
5965
|
+
const looksCancelled = res.status === 403 || errorText.includes("cancel") || errorText.includes("install limit");
|
|
5966
|
+
return {
|
|
5967
|
+
ok: false,
|
|
5968
|
+
error: data.error || data.message || `License validation failed (HTTP ${res.status})`,
|
|
5969
|
+
reason: looksCancelled ? "cancelled" : "unauthorized"
|
|
5970
|
+
};
|
|
5864
5971
|
}
|
|
5865
5972
|
async function validateGhl(apiKey2, locationId2) {
|
|
5866
5973
|
try {
|
|
@@ -5908,9 +6015,14 @@ function registerSetupTool(server2) {
|
|
|
5908
6015
|
ghl_api_key: import_zod37.z.string().min(10).describe("GHL Private Integration key (starts with 'pit-'). Created INSIDE the sub-account at Settings > Integrations > Private Integrations."),
|
|
5909
6016
|
ghl_location_id: import_zod37.z.string().min(10).describe("GHL Location ID (sub-account ID). Found in your GHL URL: /location/THIS_PART/dashboard."),
|
|
5910
6017
|
ghl_company_id: import_zod37.z.string().optional().describe("(Agency only) Company ID for multi-location access."),
|
|
5911
|
-
|
|
5912
|
-
|
|
5913
|
-
|
|
6018
|
+
// v3.25.0: one-paste shortcut. Run `auto_capture_firebase_script` first;
|
|
6019
|
+
// it returns a console script that fills the clipboard with this exact
|
|
6020
|
+
// JSON payload. Pasting it here removes the need to fill ghl_user_id,
|
|
6021
|
+
// ghl_firebase_api_key, and ghl_firebase_refresh_token individually.
|
|
6022
|
+
firebase_paste: import_zod37.z.string().optional().describe("(Workflow Builder, one-paste path) Paste the JSON output from auto_capture_firebase_script here. Replaces the three separate Firebase fields below."),
|
|
6023
|
+
ghl_user_id: import_zod37.z.string().optional().describe("(Workflow Builder, manual path) Firebase User ID. Prefer firebase_paste instead."),
|
|
6024
|
+
ghl_firebase_api_key: import_zod37.z.string().optional().describe("(Workflow Builder, manual path) Firebase API Key starting with 'AIza'. Prefer firebase_paste instead."),
|
|
6025
|
+
ghl_firebase_refresh_token: import_zod37.z.string().optional().describe("(Workflow Builder, manual path) Firebase refresh token. Prefer firebase_paste instead.")
|
|
5914
6026
|
},
|
|
5915
6027
|
async (args) => {
|
|
5916
6028
|
const lic = await validateLicense(args.email, args.license_key);
|
|
@@ -5923,24 +6035,42 @@ Purchase a license at https://elitedcs.com/ghl-mcp-server or contact support.` }
|
|
|
5923
6035
|
if (!ghl.ok) {
|
|
5924
6036
|
return { content: [{ type: "text", text: `GHL credential check failed: ${ghl.error}` }], isError: true };
|
|
5925
6037
|
}
|
|
5926
|
-
|
|
6038
|
+
let resolvedUserId = args.ghl_user_id;
|
|
6039
|
+
let resolvedFbApi = args.ghl_firebase_api_key;
|
|
6040
|
+
let resolvedFbRefresh = args.ghl_firebase_refresh_token;
|
|
6041
|
+
if (args.firebase_paste) {
|
|
6042
|
+
const parsed = parseFirebasePaste(args.firebase_paste);
|
|
6043
|
+
if (!parsed) {
|
|
6044
|
+
return {
|
|
6045
|
+
content: [{
|
|
6046
|
+
type: "text",
|
|
6047
|
+
text: "Couldn't parse firebase_paste. Expected the JSON output from auto_capture_firebase_script. Re-run that tool, follow the steps, and paste the entire JSON object it copies to your clipboard."
|
|
6048
|
+
}],
|
|
6049
|
+
isError: true
|
|
6050
|
+
};
|
|
6051
|
+
}
|
|
6052
|
+
resolvedUserId = parsed.ghl_user_id;
|
|
6053
|
+
resolvedFbApi = parsed.ghl_firebase_api_key;
|
|
6054
|
+
resolvedFbRefresh = parsed.ghl_firebase_refresh_token;
|
|
6055
|
+
}
|
|
6056
|
+
const wantsWorkflowBuilder = resolvedUserId || resolvedFbApi || resolvedFbRefresh;
|
|
5927
6057
|
let workflowBuilderEnabled = false;
|
|
5928
6058
|
let workflowBuilderNote = "";
|
|
5929
6059
|
if (wantsWorkflowBuilder) {
|
|
5930
|
-
if (!
|
|
6060
|
+
if (!resolvedUserId || !resolvedFbApi || !resolvedFbRefresh) {
|
|
5931
6061
|
return {
|
|
5932
6062
|
content: [{
|
|
5933
6063
|
type: "text",
|
|
5934
|
-
text: "Workflow Builder requires ALL THREE Firebase fields: ghl_user_id, ghl_firebase_api_key, ghl_firebase_refresh_token.
|
|
6064
|
+
text: "Workflow Builder requires ALL THREE Firebase fields: ghl_user_id, ghl_firebase_api_key, ghl_firebase_refresh_token. The easy way is `auto_capture_firebase_script` -> paste the JSON output into `firebase_paste`. Manual capture steps: https://elitedcs.com/ghl-mcp-firebase"
|
|
5935
6065
|
}],
|
|
5936
6066
|
isError: true
|
|
5937
6067
|
};
|
|
5938
6068
|
}
|
|
5939
|
-
const fb = await validateFirebase(
|
|
6069
|
+
const fb = await validateFirebase(resolvedFbApi, resolvedFbRefresh);
|
|
5940
6070
|
if (!fb.ok) {
|
|
5941
6071
|
workflowBuilderNote = `
|
|
5942
6072
|
|
|
5943
|
-
Note: Firebase credentials rejected (${fb.error}). Saved without Workflow Builder. Re-run setup_ghl_mcp later with fresh Firebase fields to enable it.`;
|
|
6073
|
+
Note: Firebase credentials rejected (${fb.error}). Saved without Workflow Builder. Re-run setup_ghl_mcp later with fresh Firebase fields (use auto_capture_firebase_script for a one-paste flow) to enable it.`;
|
|
5944
6074
|
} else {
|
|
5945
6075
|
workflowBuilderEnabled = true;
|
|
5946
6076
|
}
|
|
@@ -5952,9 +6082,9 @@ Note: Firebase credentials rejected (${fb.error}). Saved without Workflow Builde
|
|
|
5952
6082
|
ghl_api_key: args.ghl_api_key.trim(),
|
|
5953
6083
|
ghl_location_id: args.ghl_location_id.trim(),
|
|
5954
6084
|
ghl_company_id: args.ghl_company_id?.trim() || void 0,
|
|
5955
|
-
ghl_user_id: workflowBuilderEnabled ?
|
|
5956
|
-
ghl_firebase_api_key: workflowBuilderEnabled ?
|
|
5957
|
-
ghl_firebase_refresh_token: workflowBuilderEnabled ?
|
|
6085
|
+
ghl_user_id: workflowBuilderEnabled ? resolvedUserId?.trim() : void 0,
|
|
6086
|
+
ghl_firebase_api_key: workflowBuilderEnabled ? resolvedFbApi?.trim() : void 0,
|
|
6087
|
+
ghl_firebase_refresh_token: workflowBuilderEnabled ? resolvedFbRefresh?.trim() : void 0,
|
|
5958
6088
|
// v3.24.0+ attestation. Tied to email + license + device fingerprint;
|
|
5959
6089
|
// verified on every MCP startup. Closes the hand-crafted creds bypass.
|
|
5960
6090
|
signed_attestation: lic.signedAttestation
|
|
@@ -5987,11 +6117,16 @@ Note: Firebase credentials rejected (${fb.error}). Saved without Workflow Builde
|
|
|
5987
6117
|
function registerEnableWorkflowBuilderTool(server2) {
|
|
5988
6118
|
server2.tool(
|
|
5989
6119
|
"enable_workflow_builder",
|
|
5990
|
-
"Add Firebase credentials to an existing GHL Command install to unlock 49 additional tools across the internal-API modules: workflow builder (create/edit/clone/delete/publish/validate workflows, build_if_else_branch, build_goal_event, get_trigger_registry), funnel + page builder, form builder, pipeline builder, workflow cloner, smart lists, reputation, email campaigns, email templates, and memberships, plus the pre-deploy validator. Requires you've already run setup_ghl_mcp.
|
|
6120
|
+
"Add Firebase credentials to an existing GHL Command install to unlock 49 additional tools across the internal-API modules: workflow builder (create/edit/clone/delete/publish/validate workflows, build_if_else_branch, build_goal_event, get_trigger_registry), funnel + page builder, form builder, pipeline builder, workflow cloner, smart lists, reputation, email campaigns, email templates, and memberships, plus the pre-deploy validator. Requires you've already run setup_ghl_mcp. EASIEST PATH: run `auto_capture_firebase_script` first, paste the JSON output into `firebase_paste` here. MANUAL PATH: capture the three Firebase fields via DevTools and pass them individually. Tool count goes from 163 to 212 after the next Claude restart.",
|
|
5991
6121
|
{
|
|
5992
|
-
|
|
5993
|
-
|
|
5994
|
-
|
|
6122
|
+
// v3.25.0: one-paste path. Tool runs `auto_capture_firebase_script` to
|
|
6123
|
+
// get the console script; the script returns a JSON object that pastes
|
|
6124
|
+
// cleanly into this field. Saves the buyer from picking out three
|
|
6125
|
+
// separate fields in IndexedDB.
|
|
6126
|
+
firebase_paste: import_zod37.z.string().optional().describe("Paste the JSON output from auto_capture_firebase_script here. Replaces the three separate Firebase fields below."),
|
|
6127
|
+
ghl_user_id: import_zod37.z.string().min(10).optional().describe("(Manual path) Firebase User ID (uid). Prefer firebase_paste."),
|
|
6128
|
+
ghl_firebase_api_key: import_zod37.z.string().min(10).optional().describe("(Manual path) Firebase API Key starting with 'AIza'. Prefer firebase_paste."),
|
|
6129
|
+
ghl_firebase_refresh_token: import_zod37.z.string().min(10).optional().describe("(Manual path) Firebase refresh token. Prefer firebase_paste.")
|
|
5995
6130
|
},
|
|
5996
6131
|
async (args) => {
|
|
5997
6132
|
const existing = readCredentials();
|
|
@@ -6004,7 +6139,34 @@ function registerEnableWorkflowBuilderTool(server2) {
|
|
|
6004
6139
|
isError: true
|
|
6005
6140
|
};
|
|
6006
6141
|
}
|
|
6007
|
-
|
|
6142
|
+
let userId = args.ghl_user_id;
|
|
6143
|
+
let fbApi = args.ghl_firebase_api_key;
|
|
6144
|
+
let fbRefresh = args.ghl_firebase_refresh_token;
|
|
6145
|
+
if (args.firebase_paste) {
|
|
6146
|
+
const parsed = parseFirebasePaste(args.firebase_paste);
|
|
6147
|
+
if (!parsed) {
|
|
6148
|
+
return {
|
|
6149
|
+
content: [{
|
|
6150
|
+
type: "text",
|
|
6151
|
+
text: "Couldn't parse firebase_paste. Expected the JSON output from auto_capture_firebase_script. Re-run that tool, follow the steps, and paste the entire JSON object it copies to your clipboard."
|
|
6152
|
+
}],
|
|
6153
|
+
isError: true
|
|
6154
|
+
};
|
|
6155
|
+
}
|
|
6156
|
+
userId = parsed.ghl_user_id;
|
|
6157
|
+
fbApi = parsed.ghl_firebase_api_key;
|
|
6158
|
+
fbRefresh = parsed.ghl_firebase_refresh_token;
|
|
6159
|
+
}
|
|
6160
|
+
if (!userId || !fbApi || !fbRefresh) {
|
|
6161
|
+
return {
|
|
6162
|
+
content: [{
|
|
6163
|
+
type: "text",
|
|
6164
|
+
text: "Workflow Builder needs all three Firebase fields. Easiest: run `auto_capture_firebase_script`, follow the prompt, then pass the JSON output as `firebase_paste`. Manual capture: https://elitedcs.com/ghl-mcp-firebase"
|
|
6165
|
+
}],
|
|
6166
|
+
isError: true
|
|
6167
|
+
};
|
|
6168
|
+
}
|
|
6169
|
+
const fb = await validateFirebase(fbApi.trim(), fbRefresh.trim());
|
|
6008
6170
|
if (!fb.ok) {
|
|
6009
6171
|
return {
|
|
6010
6172
|
content: [{
|
|
@@ -6012,7 +6174,7 @@ function registerEnableWorkflowBuilderTool(server2) {
|
|
|
6012
6174
|
text: `Firebase credentials rejected: ${fb.error}
|
|
6013
6175
|
|
|
6014
6176
|
Common causes:
|
|
6015
|
-
- The refresh token has rotated (they rotate every few weeks). Re-
|
|
6177
|
+
- The refresh token has rotated (they rotate every few weeks). Re-run auto_capture_firebase_script for a fresh one.
|
|
6016
6178
|
- The Firebase API Key doesn't match the refresh token's project. Both must come from the SAME firebase:authUser row.
|
|
6017
6179
|
|
|
6018
6180
|
DevTools steps: https://elitedcs.com/ghl-mcp-firebase`
|
|
@@ -6022,9 +6184,9 @@ DevTools steps: https://elitedcs.com/ghl-mcp-firebase`
|
|
|
6022
6184
|
}
|
|
6023
6185
|
writeCredentials({
|
|
6024
6186
|
...existing,
|
|
6025
|
-
ghl_user_id:
|
|
6026
|
-
ghl_firebase_api_key:
|
|
6027
|
-
ghl_firebase_refresh_token:
|
|
6187
|
+
ghl_user_id: userId.trim(),
|
|
6188
|
+
ghl_firebase_api_key: fbApi.trim(),
|
|
6189
|
+
ghl_firebase_refresh_token: fbRefresh.trim()
|
|
6028
6190
|
});
|
|
6029
6191
|
return {
|
|
6030
6192
|
content: [{
|
|
@@ -6032,19 +6194,56 @@ DevTools steps: https://elitedcs.com/ghl-mcp-firebase`
|
|
|
6032
6194
|
text: [
|
|
6033
6195
|
"Workflow Builder enabled!",
|
|
6034
6196
|
"",
|
|
6035
|
-
"Firebase credentials verified and saved.",
|
|
6197
|
+
"Firebase credentials verified and saved to credentials.json.",
|
|
6036
6198
|
"",
|
|
6037
|
-
"**
|
|
6199
|
+
"**You MUST restart Claude before using any workflow-builder tool.** Quit Claude completely (Cmd+Q on Mac, full exit on Windows) and reopen. Without a restart, the workflow builder tools will keep using the OLD Firebase auth from before this call and fail with 401 errors \u2014 even though this tool reported success.",
|
|
6038
6200
|
"",
|
|
6039
|
-
'After restart,
|
|
6201
|
+
'After restart, all 212 tools load. Try: "List my workflows in full detail" or "Validate workflow <id>".',
|
|
6040
6202
|
"",
|
|
6041
|
-
"Note: Firebase refresh tokens rotate every few weeks. If workflow tools stop working,
|
|
6203
|
+
"Note: Firebase refresh tokens rotate every few weeks. If workflow tools stop working in a few weeks (run `health_check` to confirm Firebase auth: FAIL), run `auto_capture_firebase_script` for fresh values and re-run this tool with the new firebase_paste."
|
|
6042
6204
|
].join("\n")
|
|
6043
6205
|
}]
|
|
6044
6206
|
};
|
|
6045
6207
|
}
|
|
6046
6208
|
);
|
|
6047
6209
|
}
|
|
6210
|
+
function registerFirebaseCaptureScriptTool(server2) {
|
|
6211
|
+
server2.tool(
|
|
6212
|
+
"auto_capture_firebase_script",
|
|
6213
|
+
"Get the browser-console script that auto-extracts the 3 Firebase fields needed to enable the Workflow Builder. Run this, copy the script, paste it into Chrome DevTools Console on a tab logged into GHL, press Enter, and the result lands in your clipboard. Then paste the JSON into setup_ghl_mcp's firebase_paste field (or enable_workflow_builder's). No manual IndexedDB digging.",
|
|
6214
|
+
{},
|
|
6215
|
+
async () => {
|
|
6216
|
+
const steps = [
|
|
6217
|
+
"## One-paste Firebase capture",
|
|
6218
|
+
"",
|
|
6219
|
+
"**Step 1.** Open your GoHighLevel account in Chrome. Make sure you're logged in.",
|
|
6220
|
+
"",
|
|
6221
|
+
"**Step 2.** Open DevTools \u2014 press `F12` on Windows / Linux, or `Cmd+Option+I` on Mac. Click the **Console** tab.",
|
|
6222
|
+
"",
|
|
6223
|
+
"**Step 3.** Copy the script below, paste it into the Console, and press Enter:",
|
|
6224
|
+
"",
|
|
6225
|
+
"```javascript",
|
|
6226
|
+
FIREBASE_CAPTURE_SCRIPT,
|
|
6227
|
+
"```",
|
|
6228
|
+
"",
|
|
6229
|
+
"**Step 4.** The script prints a JSON object and copies it to your clipboard automatically. Looks like this (your values will be longer):",
|
|
6230
|
+
"",
|
|
6231
|
+
"```json",
|
|
6232
|
+
"{",
|
|
6233
|
+
` "ghl_firebase_api_key": "AIza...",`,
|
|
6234
|
+
` "ghl_user_id": "abc...",`,
|
|
6235
|
+
` "ghl_firebase_refresh_token": "AMf-..."`,
|
|
6236
|
+
"}",
|
|
6237
|
+
"```",
|
|
6238
|
+
"",
|
|
6239
|
+
"**Step 5.** Come back to Claude. Run `setup_ghl_mcp` (first-time install) or `enable_workflow_builder` (already set up) and paste the JSON into the `firebase_paste` field. The MCP parses it automatically \u2014 you don't need to fill the individual `ghl_user_id`, `ghl_firebase_api_key`, `ghl_firebase_refresh_token` fields.",
|
|
6240
|
+
"",
|
|
6241
|
+
"Step-by-step screenshots: https://elitedcs.com/ghl-mcp-firebase"
|
|
6242
|
+
].join("\n");
|
|
6243
|
+
return { content: [{ type: "text", text: steps }] };
|
|
6244
|
+
}
|
|
6245
|
+
);
|
|
6246
|
+
}
|
|
6048
6247
|
function registerLeadCaptureTool(server2) {
|
|
6049
6248
|
server2.tool(
|
|
6050
6249
|
"request_license",
|
|
@@ -8375,12 +8574,6 @@ async function verifyAttestation(token, bindings, now = /* @__PURE__ */ new Date
|
|
|
8375
8574
|
}
|
|
8376
8575
|
return { ok: false, reason: "expired" };
|
|
8377
8576
|
}
|
|
8378
|
-
function shouldRenew(payload, now = /* @__PURE__ */ new Date()) {
|
|
8379
|
-
const expiry = Date.parse(payload.expires_at);
|
|
8380
|
-
if (!Number.isFinite(expiry)) return true;
|
|
8381
|
-
const remainingMs = expiry - now.getTime();
|
|
8382
|
-
return remainingMs < 7 * 24 * 60 * 60 * 1e3;
|
|
8383
|
-
}
|
|
8384
8577
|
|
|
8385
8578
|
// src/tools/meta.ts
|
|
8386
8579
|
function registerMetaTools(server2, installedVersion) {
|
|
@@ -8464,15 +8657,16 @@ var server = new import_mcp.McpServer({
|
|
|
8464
8657
|
});
|
|
8465
8658
|
registerMetaTools(server, pkg.version);
|
|
8466
8659
|
var inBootstrapMode = true;
|
|
8660
|
+
var TRANSITIONAL_ATTESTATION_DEADLINE = Date.parse("2026-06-15T00:00:00.000Z");
|
|
8467
8661
|
async function renewAttestation(creds) {
|
|
8468
|
-
if (!creds.email || !creds.license_key) return
|
|
8662
|
+
if (!creds.email || !creds.license_key) return "unauthorized";
|
|
8663
|
+
let lic;
|
|
8469
8664
|
try {
|
|
8470
|
-
|
|
8471
|
-
|
|
8472
|
-
|
|
8473
|
-
|
|
8474
|
-
|
|
8475
|
-
}
|
|
8665
|
+
lic = await validateLicense(creds.email, creds.license_key);
|
|
8666
|
+
} catch {
|
|
8667
|
+
return "unreachable";
|
|
8668
|
+
}
|
|
8669
|
+
if (lic.ok) {
|
|
8476
8670
|
try {
|
|
8477
8671
|
writeCredentials({
|
|
8478
8672
|
...creds,
|
|
@@ -8482,12 +8676,23 @@ async function renewAttestation(creds) {
|
|
|
8482
8676
|
} catch {
|
|
8483
8677
|
}
|
|
8484
8678
|
if (!lic.signedAttestation) {
|
|
8485
|
-
process.stderr.write("[ghl-mcp] License re-validated, but the license server did not return a signed attestation.
|
|
8679
|
+
process.stderr.write("[ghl-mcp] License re-validated, but the license server did not return a signed attestation. This path closes on " + new Date(TRANSITIONAL_ATTESTATION_DEADLINE).toISOString().slice(0, 10) + ".\n");
|
|
8680
|
+
return "renewed-unsigned";
|
|
8486
8681
|
}
|
|
8487
|
-
return
|
|
8488
|
-
} catch {
|
|
8489
|
-
return false;
|
|
8682
|
+
return "renewed";
|
|
8490
8683
|
}
|
|
8684
|
+
if (lic.reason === "cancelled" || lic.reason === "unauthorized") {
|
|
8685
|
+
try {
|
|
8686
|
+
writeCredentials({ ...creds, signed_attestation: void 0 });
|
|
8687
|
+
process.stderr.write(`[ghl-mcp] Server rejected license (${lic.reason}). Dropping to bootstrap mode.
|
|
8688
|
+
`);
|
|
8689
|
+
} catch {
|
|
8690
|
+
}
|
|
8691
|
+
return lic.reason;
|
|
8692
|
+
}
|
|
8693
|
+
process.stderr.write(`[ghl-mcp] License re-validate unreachable: ${lic.error}
|
|
8694
|
+
`);
|
|
8695
|
+
return "unreachable";
|
|
8491
8696
|
}
|
|
8492
8697
|
function renewAttestationInBackground(creds) {
|
|
8493
8698
|
return renewAttestation(creds).then(() => void 0, () => void 0);
|
|
@@ -8503,14 +8708,15 @@ async function resolveAccessAndRegister() {
|
|
|
8503
8708
|
if (verify.ok) {
|
|
8504
8709
|
licenseVerified = true;
|
|
8505
8710
|
attestationStatus = verify.reason;
|
|
8506
|
-
|
|
8507
|
-
void renewAttestationInBackground(fileCreds);
|
|
8508
|
-
}
|
|
8711
|
+
void renewAttestationInBackground(fileCreds);
|
|
8509
8712
|
} else {
|
|
8510
8713
|
process.stderr.write(`[ghl-mcp] Stored attestation rejected: ${verify.reason}. Re-validating online.
|
|
8511
8714
|
`);
|
|
8512
8715
|
const renewed = await renewAttestation(fileCreds);
|
|
8513
|
-
if (renewed) {
|
|
8716
|
+
if (renewed === "renewed") {
|
|
8717
|
+
licenseVerified = true;
|
|
8718
|
+
attestationStatus = "renewed-after-tamper";
|
|
8719
|
+
} else if (renewed === "renewed-unsigned" && Date.now() < TRANSITIONAL_ATTESTATION_DEADLINE) {
|
|
8514
8720
|
licenseVerified = true;
|
|
8515
8721
|
attestationStatus = "renewed-after-tamper";
|
|
8516
8722
|
} else {
|
|
@@ -8520,12 +8726,22 @@ async function resolveAccessAndRegister() {
|
|
|
8520
8726
|
} else if (fileCreds?.email && fileCreds?.license_key) {
|
|
8521
8727
|
process.stderr.write("[ghl-mcp] Upgrading legacy credentials.json to signed attestation.\n");
|
|
8522
8728
|
const renewed = await renewAttestation(fileCreds);
|
|
8523
|
-
if (renewed) {
|
|
8729
|
+
if (renewed === "renewed") {
|
|
8730
|
+
licenseVerified = true;
|
|
8731
|
+
attestationStatus = "renewed-from-legacy";
|
|
8732
|
+
} else if (renewed === "renewed-unsigned" && Date.now() < TRANSITIONAL_ATTESTATION_DEADLINE) {
|
|
8524
8733
|
licenseVerified = true;
|
|
8525
8734
|
attestationStatus = "renewed-from-legacy";
|
|
8526
8735
|
} else {
|
|
8527
8736
|
attestationStatus = "needs-reset";
|
|
8528
|
-
|
|
8737
|
+
if (renewed === "cancelled" || renewed === "unauthorized") {
|
|
8738
|
+
process.stderr.write(`[ghl-mcp] Server says license is ${renewed}. Bootstrap mode.
|
|
8739
|
+
`);
|
|
8740
|
+
} else if (renewed === "renewed-unsigned") {
|
|
8741
|
+
process.stderr.write("[ghl-mcp] Server is in unsigned-attestation mode past the transition deadline. Bootstrap mode.\n");
|
|
8742
|
+
} else {
|
|
8743
|
+
process.stderr.write("[ghl-mcp] Could not re-validate. Run setup_ghl_mcp again.\n");
|
|
8744
|
+
}
|
|
8529
8745
|
}
|
|
8530
8746
|
}
|
|
8531
8747
|
if (!licenseVerified && apiKey && locationId) {
|
|
@@ -8533,7 +8749,8 @@ async function resolveAccessAndRegister() {
|
|
|
8533
8749
|
const licKey = (process.env.GHL_LICENSE_KEY || fileCreds?.license_key)?.trim();
|
|
8534
8750
|
if (licEmail && licKey) {
|
|
8535
8751
|
const lic = await validateLicense(licEmail, licKey);
|
|
8536
|
-
|
|
8752
|
+
const acceptUnsigned = !lic.ok ? false : lic.signedAttestation || Date.now() < TRANSITIONAL_ATTESTATION_DEADLINE;
|
|
8753
|
+
if (lic.ok && acceptUnsigned) {
|
|
8537
8754
|
licenseVerified = true;
|
|
8538
8755
|
attestationStatus = "renewed";
|
|
8539
8756
|
try {
|
|
@@ -8571,15 +8788,17 @@ async function resolveAccessAndRegister() {
|
|
|
8571
8788
|
process.stderr.write(
|
|
8572
8789
|
`[ghl-mcp] Bootstrap mode.
|
|
8573
8790
|
[ghl-mcp] Need a valid license plus GHL_API_KEY + GHL_LOCATION_ID (env vars or credentials file at ${credentialsPath()}).
|
|
8574
|
-
[ghl-mcp]
|
|
8791
|
+
[ghl-mcp] Available: setup_ghl_mcp, request_license, auto_capture_firebase_script, get_mcp_version. Run setup_ghl_mcp with your license + GHL credentials \u2014 or request_license if you don't have a license yet.
|
|
8575
8792
|
`
|
|
8576
8793
|
);
|
|
8577
8794
|
registerSetupTool(server);
|
|
8578
8795
|
registerLeadCaptureTool(server);
|
|
8796
|
+
registerFirebaseCaptureScriptTool(server);
|
|
8579
8797
|
} else {
|
|
8580
8798
|
const client = new GHLClient({ apiKey, locationId });
|
|
8581
8799
|
registerAllTools(server, client, registry, pkg.version);
|
|
8582
8800
|
registerEnableWorkflowBuilderTool(server);
|
|
8801
|
+
registerFirebaseCaptureScriptTool(server);
|
|
8583
8802
|
if (fileCreds && !process.env.GHL_API_KEY) {
|
|
8584
8803
|
process.stderr.write(`[ghl-mcp] Loaded credentials from ${credentialsPath()}
|
|
8585
8804
|
`);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@elitedcs/ghl-mcp",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.26.0",
|
|
4
4
|
"mcpName": "io.github.drjerryrelth/ghl-command",
|
|
5
5
|
"description": "GoHighLevel MCP Server for Claude. 212 tools — full CRM, automation, marketing control, and the only programmatic GHL workflow builder, now multi-tenant across client accounts.",
|
|
6
6
|
"main": "dist/index.js",
|