@elitedcs/ghl-mcp 3.23.0 → 3.25.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/CHANGELOG.md +43 -0
  2. package/dist/index.js +365 -26
  3. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -1,5 +1,48 @@
1
1
  # Changelog
2
2
 
3
+ ## 3.25.0 — One-paste Firebase capture
4
+
5
+ **New bootstrap-and-normal-mode tool. Tool count goes from 212 to 213 (the new tool counts whether you're set up or not).**
6
+
7
+ 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.
8
+
9
+ **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.
10
+
11
+ **Updated tools:**
12
+
13
+ - `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).
14
+ - `enable_workflow_builder` accepts the same `firebase_paste` parameter, again with the three separate fields as fallback.
15
+
16
+ **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).
17
+
18
+ **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.
19
+
20
+ 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).
21
+
22
+ ## 3.24.0 — Signed-attestation gate closes the credentials.json bypass
23
+
24
+ **Security fix. Tool count unchanged (212 across 43 modules).**
25
+
26
+ Through v3.23.0 the license gate trusted any `credentials.json` whose `verified_at` and `license_key` fields were non-empty strings. A technical user could hand-write the file with their own GHL API key and load all 163 core tools without paying. v3.24.0 replaces that trust with an Ed25519-signed attestation issued by `elitedcs.com/api/validate-license`. The MCP bundles only the public key, so it can verify but can't forge — hand-crafted credentials now fail on the next startup and the MCP falls into bootstrap mode.
27
+
28
+ **How it works.** Every successful online license validation now returns a `signed_attestation` token binding `{email, license_key, device_fingerprint, installs_max, expires_at}` together. The MCP stores this in `credentials.json` and verifies it on startup:
29
+
30
+ - **Valid + fresh** → boot normally.
31
+ - **Valid + nearing expiry (<7d)** → boot normally, refresh in the background.
32
+ - **Expired but within 14-day grace window** → boot normally for this session, attempt re-renew (covers transient license-server outages).
33
+ - **Past grace OR bad signature OR wrong device OR wrong email/license** → force online re-validate. If the server confirms the buyer, mint a new attestation. If not, drop into bootstrap mode and require `setup_ghl_mcp`.
34
+
35
+ **Migration is automatic.** Existing v3.20.0–v3.23.0 buyers have a `credentials.json` without `signed_attestation`. On first startup under v3.24.0 the MCP detects the missing field, calls `validate-license`, and writes the new signed token. Buyers see a one-time `[ghl-mcp] License gate: renewed-from-legacy` log line and nothing else changes. The transitional path also tolerates a license server that hasn't been upgraded yet — `setup_ghl_mcp` still works against an older server, the attestation just gets backfilled on the next restart after the server deploys.
36
+
37
+ **Funnel telemetry shipped on the server side (no MCP impact).** `validate-license`, `capture-lead`, and `stripe-webhook` now emit `[telemetry] {event, success, reason, email_hash, ...}` log lines that Cloudflare captures. Lets us measure the npm-install → setup → purchase funnel without storing PII.
38
+
39
+ **Pipeline automation shipped on the server side (no MCP impact).** A successful `validate-license` call now advances the buyer's GHL Command opportunity:
40
+
41
+ - Purchased → Onboarding on first install (installs_used 0 → 1).
42
+ - Onboarding → Active on second+ install or any reinstall.
43
+
44
+ Existing buyers backfilled manually based on their current `installs_used` count.
45
+
3
46
  ## 3.23.0 — `update_calendar` can assign team members again (Henry fix, part 2)
4
47
 
5
48
  **Critical fix. Tool count unchanged (212 across 43 modules). The `teamMembers` parameter is back on `update_calendar` — and actually persists this time — with a typed schema that surfaces GHL's strict validation up front.**
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.23.0",
34
+ version: "3.25.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",
@@ -290,7 +290,14 @@ var CredentialsSchema = import_zod.z.object({
290
290
  ghl_company_id: import_zod.z.string().optional(),
291
291
  ghl_user_id: import_zod.z.string().optional(),
292
292
  ghl_firebase_api_key: import_zod.z.string().optional(),
293
- ghl_firebase_refresh_token: import_zod.z.string().optional()
293
+ ghl_firebase_refresh_token: import_zod.z.string().optional(),
294
+ // Ed25519-signed attestation from elitedcs.com/api/validate-license.
295
+ // v3.24.0+ writes this on every successful online validation; index.ts
296
+ // verifies on startup and refuses to load tools without it (closing the
297
+ // hand-crafted-credentials.json bypass that existed through v3.23.0).
298
+ // Optional in the schema only so files written by earlier versions still
299
+ // parse — index.ts's gate forces a re-validate when the field is missing.
300
+ signed_attestation: import_zod.z.string().optional()
294
301
  });
295
302
  function appDataDir() {
296
303
  const home = os.homedir();
@@ -5821,6 +5828,83 @@ var import_zod38 = require("zod");
5821
5828
  var os2 = __toESM(require("os"));
5822
5829
  var crypto2 = __toESM(require("crypto"));
5823
5830
  var import_zod37 = require("zod");
5831
+
5832
+ // src/firebase-capture-script.ts
5833
+ var FIREBASE_CAPTURE_SCRIPT = `(async () => {
5834
+ try {
5835
+ const db = await new Promise((res, rej) => {
5836
+ const r = indexedDB.open('firebaseLocalStorageDb');
5837
+ r.onsuccess = () => res(r.result);
5838
+ r.onerror = () => rej(r.error);
5839
+ });
5840
+ const tx = db.transaction('firebaseLocalStorage', 'readonly');
5841
+ const store = tx.objectStore('firebaseLocalStorage');
5842
+ const rows = await new Promise((res, rej) => {
5843
+ const r = store.getAll();
5844
+ r.onsuccess = () => res(r.result);
5845
+ r.onerror = () => rej(r.error);
5846
+ });
5847
+ const row = rows.find(r => typeof r?.fbase_key === 'string' && r.fbase_key.startsWith('firebase:authUser:AIza'));
5848
+ if (!row) {
5849
+ console.log('%cGHL Command: no Firebase login found.', 'color:red;font-weight:bold');
5850
+ console.log('Open a tab logged into your GHL account, then run this again in that tab.');
5851
+ return;
5852
+ }
5853
+ const v = row.value || {};
5854
+ const out = {
5855
+ ghl_firebase_api_key: v.apiKey,
5856
+ ghl_user_id: v.uid,
5857
+ ghl_firebase_refresh_token: v.stsTokenManager?.refreshToken,
5858
+ };
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
+ const json = JSON.stringify(out, null, 2);
5865
+ console.log('%cGHL Command: paste this into setup_ghl_mcp (firebase_paste field):', 'color:green;font-weight:bold');
5866
+ console.log(json);
5867
+ try {
5868
+ await navigator.clipboard.writeText(json);
5869
+ console.log('%cAlready copied to your clipboard \u2014 Cmd+V / Ctrl+V to paste.', 'color:green');
5870
+ } catch (e) {
5871
+ console.log('%cCopy it manually from above (clipboard access not granted).', 'color:orange');
5872
+ }
5873
+ } catch (err) {
5874
+ console.log('%cGHL Command: script error.', 'color:red;font-weight:bold');
5875
+ console.log(err);
5876
+ }
5877
+ })();`;
5878
+ function parseFirebasePaste(input) {
5879
+ if (typeof input !== "string") return null;
5880
+ let cleaned = input.trim();
5881
+ cleaned = cleaned.replace(/^```(?:json)?\s*/i, "").replace(/```$/i, "").trim();
5882
+ cleaned = cleaned.replace(/[“”]/g, '"').replace(/[‘’]/g, "'");
5883
+ let parsed;
5884
+ try {
5885
+ parsed = JSON.parse(cleaned);
5886
+ } catch {
5887
+ return null;
5888
+ }
5889
+ if (!parsed || typeof parsed !== "object") return null;
5890
+ const obj = parsed;
5891
+ const api = obj.ghl_firebase_api_key;
5892
+ const uid = obj.ghl_user_id;
5893
+ const refresh = obj.ghl_firebase_refresh_token;
5894
+ if (typeof api !== "string" || typeof uid !== "string" || typeof refresh !== "string") return null;
5895
+ const trimmedApi = api.trim();
5896
+ const trimmedUid = uid.trim();
5897
+ const trimmedRefresh = refresh.trim();
5898
+ if (!trimmedApi.startsWith("AIza")) return null;
5899
+ if (!trimmedUid || !trimmedRefresh) return null;
5900
+ return {
5901
+ ghl_firebase_api_key: trimmedApi,
5902
+ ghl_user_id: trimmedUid,
5903
+ ghl_firebase_refresh_token: trimmedRefresh
5904
+ };
5905
+ }
5906
+
5907
+ // src/setup-tool.ts
5824
5908
  var LICENSE_API = "https://elitedcs.com/api/validate-license";
5825
5909
  var CAPTURE_API = "https://elitedcs.com/api/capture-lead";
5826
5910
  var GHL_API = "https://services.leadconnectorhq.com";
@@ -5843,7 +5927,11 @@ async function validateLicense(email, licenseKey) {
5843
5927
  });
5844
5928
  const data = await res.json().catch(() => ({}));
5845
5929
  if (res.ok && data.valid) {
5846
- return { ok: true, installs: `${data.installs_used}/${data.installs_max}` };
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
+ };
5847
5935
  }
5848
5936
  return { ok: false, error: data.error || data.message || `License validation failed (HTTP ${res.status})` };
5849
5937
  } catch (err) {
@@ -5897,9 +5985,14 @@ function registerSetupTool(server2) {
5897
5985
  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."),
5898
5986
  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."),
5899
5987
  ghl_company_id: import_zod37.z.string().optional().describe("(Agency only) Company ID for multi-location access."),
5900
- ghl_user_id: import_zod37.z.string().optional().describe("(Workflow Builder, optional) Firebase User ID. See README for browser capture instructions."),
5901
- ghl_firebase_api_key: import_zod37.z.string().optional().describe("(Workflow Builder, optional) Firebase API Key starting with 'AIza'."),
5902
- ghl_firebase_refresh_token: import_zod37.z.string().optional().describe("(Workflow Builder, optional) Firebase refresh token starting with 'AMf-'.")
5988
+ // v3.25.0: one-paste shortcut. Run `auto_capture_firebase_script` first;
5989
+ // it returns a console script that fills the clipboard with this exact
5990
+ // JSON payload. Pasting it here removes the need to fill ghl_user_id,
5991
+ // ghl_firebase_api_key, and ghl_firebase_refresh_token individually.
5992
+ 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."),
5993
+ ghl_user_id: import_zod37.z.string().optional().describe("(Workflow Builder, manual path) Firebase User ID. Prefer firebase_paste instead."),
5994
+ ghl_firebase_api_key: import_zod37.z.string().optional().describe("(Workflow Builder, manual path) Firebase API Key starting with 'AIza'. Prefer firebase_paste instead."),
5995
+ ghl_firebase_refresh_token: import_zod37.z.string().optional().describe("(Workflow Builder, manual path) Firebase refresh token. Prefer firebase_paste instead.")
5903
5996
  },
5904
5997
  async (args) => {
5905
5998
  const lic = await validateLicense(args.email, args.license_key);
@@ -5912,24 +6005,42 @@ Purchase a license at https://elitedcs.com/ghl-mcp-server or contact support.` }
5912
6005
  if (!ghl.ok) {
5913
6006
  return { content: [{ type: "text", text: `GHL credential check failed: ${ghl.error}` }], isError: true };
5914
6007
  }
5915
- const wantsWorkflowBuilder = args.ghl_user_id || args.ghl_firebase_api_key || args.ghl_firebase_refresh_token;
6008
+ let resolvedUserId = args.ghl_user_id;
6009
+ let resolvedFbApi = args.ghl_firebase_api_key;
6010
+ let resolvedFbRefresh = args.ghl_firebase_refresh_token;
6011
+ if (args.firebase_paste) {
6012
+ const parsed = parseFirebasePaste(args.firebase_paste);
6013
+ if (!parsed) {
6014
+ return {
6015
+ content: [{
6016
+ type: "text",
6017
+ 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."
6018
+ }],
6019
+ isError: true
6020
+ };
6021
+ }
6022
+ resolvedUserId = parsed.ghl_user_id;
6023
+ resolvedFbApi = parsed.ghl_firebase_api_key;
6024
+ resolvedFbRefresh = parsed.ghl_firebase_refresh_token;
6025
+ }
6026
+ const wantsWorkflowBuilder = resolvedUserId || resolvedFbApi || resolvedFbRefresh;
5916
6027
  let workflowBuilderEnabled = false;
5917
6028
  let workflowBuilderNote = "";
5918
6029
  if (wantsWorkflowBuilder) {
5919
- if (!args.ghl_user_id || !args.ghl_firebase_api_key || !args.ghl_firebase_refresh_token) {
6030
+ if (!resolvedUserId || !resolvedFbApi || !resolvedFbRefresh) {
5920
6031
  return {
5921
6032
  content: [{
5922
6033
  type: "text",
5923
- text: "Workflow Builder requires ALL THREE Firebase fields: ghl_user_id, ghl_firebase_api_key, ghl_firebase_refresh_token. Either provide all three or omit all three. See https://elitedcs.com/ghl-mcp-server/firebase for one-click capture."
6034
+ 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"
5924
6035
  }],
5925
6036
  isError: true
5926
6037
  };
5927
6038
  }
5928
- const fb = await validateFirebase(args.ghl_firebase_api_key, args.ghl_firebase_refresh_token);
6039
+ const fb = await validateFirebase(resolvedFbApi, resolvedFbRefresh);
5929
6040
  if (!fb.ok) {
5930
6041
  workflowBuilderNote = `
5931
6042
 
5932
- Note: Firebase credentials rejected (${fb.error}). Saved without Workflow Builder. Re-run setup_ghl_mcp later with fresh Firebase fields to enable it.`;
6043
+ 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.`;
5933
6044
  } else {
5934
6045
  workflowBuilderEnabled = true;
5935
6046
  }
@@ -5941,9 +6052,12 @@ Note: Firebase credentials rejected (${fb.error}). Saved without Workflow Builde
5941
6052
  ghl_api_key: args.ghl_api_key.trim(),
5942
6053
  ghl_location_id: args.ghl_location_id.trim(),
5943
6054
  ghl_company_id: args.ghl_company_id?.trim() || void 0,
5944
- ghl_user_id: workflowBuilderEnabled ? args.ghl_user_id?.trim() : void 0,
5945
- ghl_firebase_api_key: workflowBuilderEnabled ? args.ghl_firebase_api_key?.trim() : void 0,
5946
- ghl_firebase_refresh_token: workflowBuilderEnabled ? args.ghl_firebase_refresh_token?.trim() : void 0
6055
+ ghl_user_id: workflowBuilderEnabled ? resolvedUserId?.trim() : void 0,
6056
+ ghl_firebase_api_key: workflowBuilderEnabled ? resolvedFbApi?.trim() : void 0,
6057
+ ghl_firebase_refresh_token: workflowBuilderEnabled ? resolvedFbRefresh?.trim() : void 0,
6058
+ // v3.24.0+ attestation. Tied to email + license + device fingerprint;
6059
+ // verified on every MCP startup. Closes the hand-crafted creds bypass.
6060
+ signed_attestation: lic.signedAttestation
5947
6061
  });
5948
6062
  const toolCount = workflowBuilderEnabled ? "212" : "163";
5949
6063
  const wfLine = workflowBuilderEnabled ? "Workflow Builder: enabled." : "Workflow Builder: not configured (optional).";
@@ -5973,11 +6087,16 @@ Note: Firebase credentials rejected (${fb.error}). Saved without Workflow Builde
5973
6087
  function registerEnableWorkflowBuilderTool(server2) {
5974
6088
  server2.tool(
5975
6089
  "enable_workflow_builder",
5976
- "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. Capture the three Firebase values from your GHL browser session \u2014 see elitedcs.com/ghl-mcp-firebase for step-by-step DevTools instructions. Tool count goes from 163 to 212 after the next Claude restart.",
6090
+ "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.",
5977
6091
  {
5978
- ghl_user_id: import_zod37.z.string().min(10).describe("Firebase User ID (uid). DevTools \u2192 Application \u2192 IndexedDB \u2192 firebaseLocalStorageDb \u2192 firebaseLocalStorage \u2192 the value.uid field of the firebase:authUser row."),
5979
- ghl_firebase_api_key: import_zod37.z.string().min(10).describe("Firebase API Key starting with 'AIza'. The string between 'firebase:authUser:' and ':[DEFAULT]' in the row's Key column."),
5980
- ghl_firebase_refresh_token: import_zod37.z.string().min(10).describe("Firebase refresh token. value.stsTokenManager.refreshToken in the firebase:authUser row.")
6092
+ // v3.25.0: one-paste path. Tool runs `auto_capture_firebase_script` to
6093
+ // get the console script; the script returns a JSON object that pastes
6094
+ // cleanly into this field. Saves the buyer from picking out three
6095
+ // separate fields in IndexedDB.
6096
+ 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."),
6097
+ ghl_user_id: import_zod37.z.string().min(10).optional().describe("(Manual path) Firebase User ID (uid). Prefer firebase_paste."),
6098
+ ghl_firebase_api_key: import_zod37.z.string().min(10).optional().describe("(Manual path) Firebase API Key starting with 'AIza'. Prefer firebase_paste."),
6099
+ ghl_firebase_refresh_token: import_zod37.z.string().min(10).optional().describe("(Manual path) Firebase refresh token. Prefer firebase_paste.")
5981
6100
  },
5982
6101
  async (args) => {
5983
6102
  const existing = readCredentials();
@@ -5990,7 +6109,34 @@ function registerEnableWorkflowBuilderTool(server2) {
5990
6109
  isError: true
5991
6110
  };
5992
6111
  }
5993
- const fb = await validateFirebase(args.ghl_firebase_api_key.trim(), args.ghl_firebase_refresh_token.trim());
6112
+ let userId = args.ghl_user_id;
6113
+ let fbApi = args.ghl_firebase_api_key;
6114
+ let fbRefresh = args.ghl_firebase_refresh_token;
6115
+ if (args.firebase_paste) {
6116
+ const parsed = parseFirebasePaste(args.firebase_paste);
6117
+ if (!parsed) {
6118
+ return {
6119
+ content: [{
6120
+ type: "text",
6121
+ 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."
6122
+ }],
6123
+ isError: true
6124
+ };
6125
+ }
6126
+ userId = parsed.ghl_user_id;
6127
+ fbApi = parsed.ghl_firebase_api_key;
6128
+ fbRefresh = parsed.ghl_firebase_refresh_token;
6129
+ }
6130
+ if (!userId || !fbApi || !fbRefresh) {
6131
+ return {
6132
+ content: [{
6133
+ type: "text",
6134
+ 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"
6135
+ }],
6136
+ isError: true
6137
+ };
6138
+ }
6139
+ const fb = await validateFirebase(fbApi.trim(), fbRefresh.trim());
5994
6140
  if (!fb.ok) {
5995
6141
  return {
5996
6142
  content: [{
@@ -5998,7 +6144,7 @@ function registerEnableWorkflowBuilderTool(server2) {
5998
6144
  text: `Firebase credentials rejected: ${fb.error}
5999
6145
 
6000
6146
  Common causes:
6001
- - The refresh token has rotated (they rotate every few weeks). Re-extract from your GHL browser tab.
6147
+ - The refresh token has rotated (they rotate every few weeks). Re-run auto_capture_firebase_script for a fresh one.
6002
6148
  - The Firebase API Key doesn't match the refresh token's project. Both must come from the SAME firebase:authUser row.
6003
6149
 
6004
6150
  DevTools steps: https://elitedcs.com/ghl-mcp-firebase`
@@ -6008,9 +6154,9 @@ DevTools steps: https://elitedcs.com/ghl-mcp-firebase`
6008
6154
  }
6009
6155
  writeCredentials({
6010
6156
  ...existing,
6011
- ghl_user_id: args.ghl_user_id.trim(),
6012
- ghl_firebase_api_key: args.ghl_firebase_api_key.trim(),
6013
- ghl_firebase_refresh_token: args.ghl_firebase_refresh_token.trim()
6157
+ ghl_user_id: userId.trim(),
6158
+ ghl_firebase_api_key: fbApi.trim(),
6159
+ ghl_firebase_refresh_token: fbRefresh.trim()
6014
6160
  });
6015
6161
  return {
6016
6162
  content: [{
@@ -6031,6 +6177,43 @@ DevTools steps: https://elitedcs.com/ghl-mcp-firebase`
6031
6177
  }
6032
6178
  );
6033
6179
  }
6180
+ function registerFirebaseCaptureScriptTool(server2) {
6181
+ server2.tool(
6182
+ "auto_capture_firebase_script",
6183
+ "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.",
6184
+ {},
6185
+ async () => {
6186
+ const steps = [
6187
+ "## One-paste Firebase capture",
6188
+ "",
6189
+ "**Step 1.** Open your GoHighLevel account in Chrome. Make sure you're logged in.",
6190
+ "",
6191
+ "**Step 2.** Open DevTools \u2014 press `F12` on Windows / Linux, or `Cmd+Option+I` on Mac. Click the **Console** tab.",
6192
+ "",
6193
+ "**Step 3.** Copy the script below, paste it into the Console, and press Enter:",
6194
+ "",
6195
+ "```javascript",
6196
+ FIREBASE_CAPTURE_SCRIPT,
6197
+ "```",
6198
+ "",
6199
+ "**Step 4.** The script prints a JSON object and copies it to your clipboard automatically. Looks like this (your values will be longer):",
6200
+ "",
6201
+ "```json",
6202
+ "{",
6203
+ ` "ghl_firebase_api_key": "AIza...",`,
6204
+ ` "ghl_user_id": "abc...",`,
6205
+ ` "ghl_firebase_refresh_token": "AMf-..."`,
6206
+ "}",
6207
+ "```",
6208
+ "",
6209
+ "**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.",
6210
+ "",
6211
+ "Step-by-step screenshots: https://elitedcs.com/ghl-mcp-firebase"
6212
+ ].join("\n");
6213
+ return { content: [{ type: "text", text: steps }] };
6214
+ }
6215
+ );
6216
+ }
6034
6217
  function registerLeadCaptureTool(server2) {
6035
6218
  server2.tool(
6036
6219
  "request_license",
@@ -8282,6 +8465,92 @@ function registerAllTools(server2, client, registry2, mcpVersion) {
8282
8465
  registerLocationSwitcherTools(server2, client, builderClient, registry2, mcpVersion);
8283
8466
  }
8284
8467
 
8468
+ // src/attestation.ts
8469
+ var import_node_crypto = require("node:crypto");
8470
+ var ATTESTATION_PUBLIC_KEY_B64 = "8KJo8V3Z7ngeToIQfOXpKdlr/EA34hXJVEIpW0A7wqs=";
8471
+ var ATTESTATION_VERSION = 1;
8472
+ var ATTESTATION_GRACE_DAYS = 14;
8473
+ function deviceFingerprint2() {
8474
+ const os3 = require("node:os");
8475
+ const crypto4 = require("node:crypto");
8476
+ const raw = `${os3.hostname()}:${os3.userInfo().username}:${os3.platform()}:${os3.arch()}`;
8477
+ return crypto4.createHash("sha256").update(raw).digest("hex").slice(0, 16);
8478
+ }
8479
+ function base64urlDecode(s) {
8480
+ const b64 = s.replace(/-/g, "+").replace(/_/g, "/");
8481
+ const padded = b64 + "=".repeat((4 - b64.length % 4) % 4);
8482
+ return new Uint8Array(Buffer.from(padded, "base64"));
8483
+ }
8484
+ var cachedPublicKey = null;
8485
+ async function getPublicKey() {
8486
+ if (cachedPublicKey) return cachedPublicKey;
8487
+ const raw = Buffer.from(ATTESTATION_PUBLIC_KEY_B64, "base64");
8488
+ cachedPublicKey = await import_node_crypto.webcrypto.subtle.importKey(
8489
+ "raw",
8490
+ raw,
8491
+ { name: "Ed25519" },
8492
+ false,
8493
+ ["verify"]
8494
+ );
8495
+ return cachedPublicKey;
8496
+ }
8497
+ async function verifyAttestation(token, bindings, now = /* @__PURE__ */ new Date()) {
8498
+ if (typeof token !== "string" || !token) return { ok: false, reason: "malformed" };
8499
+ const dot = token.indexOf(".");
8500
+ if (dot <= 0 || dot === token.length - 1) return { ok: false, reason: "malformed" };
8501
+ let payloadBytes;
8502
+ let signatureBytes;
8503
+ try {
8504
+ payloadBytes = base64urlDecode(token.slice(0, dot));
8505
+ signatureBytes = base64urlDecode(token.slice(dot + 1));
8506
+ } catch {
8507
+ return { ok: false, reason: "malformed" };
8508
+ }
8509
+ let payload;
8510
+ try {
8511
+ const parsed = JSON.parse(new TextDecoder().decode(payloadBytes));
8512
+ if (typeof parsed.v !== "number") return { ok: false, reason: "malformed" };
8513
+ if (parsed.v !== ATTESTATION_VERSION) return { ok: false, reason: "unsupported-version" };
8514
+ payload = parsed;
8515
+ } catch {
8516
+ return { ok: false, reason: "malformed" };
8517
+ }
8518
+ let signatureValid;
8519
+ try {
8520
+ const key = await getPublicKey();
8521
+ signatureValid = await import_node_crypto.webcrypto.subtle.verify({ name: "Ed25519" }, key, signatureBytes, payloadBytes);
8522
+ } catch {
8523
+ return { ok: false, reason: "bad-signature" };
8524
+ }
8525
+ if (!signatureValid) return { ok: false, reason: "bad-signature" };
8526
+ if (payload.email.toLowerCase() !== bindings.email.toLowerCase()) {
8527
+ return { ok: false, reason: "wrong-email" };
8528
+ }
8529
+ if (payload.license_key !== bindings.license_key) {
8530
+ return { ok: false, reason: "wrong-license" };
8531
+ }
8532
+ const fingerprint = bindings.expectedFingerprint ?? deviceFingerprint2();
8533
+ if (payload.device_fingerprint !== fingerprint) {
8534
+ return { ok: false, reason: "wrong-device" };
8535
+ }
8536
+ const expiry = Date.parse(payload.expires_at);
8537
+ if (!Number.isFinite(expiry)) return { ok: false, reason: "malformed" };
8538
+ const graceDeadline = expiry + ATTESTATION_GRACE_DAYS * 24 * 60 * 60 * 1e3;
8539
+ if (now.getTime() <= expiry) {
8540
+ return { ok: true, payload, reason: "valid" };
8541
+ }
8542
+ if (now.getTime() <= graceDeadline) {
8543
+ return { ok: true, payload, reason: "in-grace" };
8544
+ }
8545
+ return { ok: false, reason: "expired" };
8546
+ }
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
+
8285
8554
  // src/tools/meta.ts
8286
8555
  function registerMetaTools(server2, installedVersion) {
8287
8556
  server2.tool(
@@ -8364,8 +8633,70 @@ var server = new import_mcp.McpServer({
8364
8633
  });
8365
8634
  registerMetaTools(server, pkg.version);
8366
8635
  var inBootstrapMode = true;
8636
+ async function renewAttestation(creds) {
8637
+ if (!creds.email || !creds.license_key) return false;
8638
+ 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
+ }
8645
+ try {
8646
+ writeCredentials({
8647
+ ...creds,
8648
+ verified_at: (/* @__PURE__ */ new Date()).toISOString(),
8649
+ ...lic.signedAttestation ? { signed_attestation: lic.signedAttestation } : {}
8650
+ });
8651
+ } catch {
8652
+ }
8653
+ 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");
8655
+ }
8656
+ return true;
8657
+ } catch {
8658
+ return false;
8659
+ }
8660
+ }
8661
+ function renewAttestationInBackground(creds) {
8662
+ return renewAttestation(creds).then(() => void 0, () => void 0);
8663
+ }
8367
8664
  async function resolveAccessAndRegister() {
8368
- let licenseVerified = Boolean(fileCreds?.verified_at && fileCreds?.license_key);
8665
+ let licenseVerified = false;
8666
+ let attestationStatus = "no-creds";
8667
+ if (fileCreds?.email && fileCreds?.license_key && fileCreds.signed_attestation) {
8668
+ const verify = await verifyAttestation(fileCreds.signed_attestation, {
8669
+ email: fileCreds.email,
8670
+ license_key: fileCreds.license_key
8671
+ });
8672
+ if (verify.ok) {
8673
+ licenseVerified = true;
8674
+ attestationStatus = verify.reason;
8675
+ if (verify.reason === "in-grace" || shouldRenew(verify.payload)) {
8676
+ void renewAttestationInBackground(fileCreds);
8677
+ }
8678
+ } else {
8679
+ process.stderr.write(`[ghl-mcp] Stored attestation rejected: ${verify.reason}. Re-validating online.
8680
+ `);
8681
+ const renewed = await renewAttestation(fileCreds);
8682
+ if (renewed) {
8683
+ licenseVerified = true;
8684
+ attestationStatus = "renewed-after-tamper";
8685
+ } else {
8686
+ attestationStatus = "needs-reset";
8687
+ }
8688
+ }
8689
+ } else if (fileCreds?.email && fileCreds?.license_key) {
8690
+ process.stderr.write("[ghl-mcp] Upgrading legacy credentials.json to signed attestation.\n");
8691
+ const renewed = await renewAttestation(fileCreds);
8692
+ if (renewed) {
8693
+ licenseVerified = true;
8694
+ attestationStatus = "renewed-from-legacy";
8695
+ } else {
8696
+ attestationStatus = "needs-reset";
8697
+ process.stderr.write("[ghl-mcp] Could not re-validate. Run setup_ghl_mcp again.\n");
8698
+ }
8699
+ }
8369
8700
  if (!licenseVerified && apiKey && locationId) {
8370
8701
  const licEmail = (process.env.GHL_LICENSE_EMAIL || fileCreds?.email)?.trim();
8371
8702
  const licKey = (process.env.GHL_LICENSE_KEY || fileCreds?.license_key)?.trim();
@@ -8373,6 +8704,7 @@ async function resolveAccessAndRegister() {
8373
8704
  const lic = await validateLicense(licEmail, licKey);
8374
8705
  if (lic.ok) {
8375
8706
  licenseVerified = true;
8707
+ attestationStatus = "renewed";
8376
8708
  try {
8377
8709
  writeCredentials({
8378
8710
  license_key: licKey,
@@ -8383,7 +8715,8 @@ async function resolveAccessAndRegister() {
8383
8715
  ...process.env.GHL_COMPANY_ID ? { ghl_company_id: process.env.GHL_COMPANY_ID } : {},
8384
8716
  ...process.env.GHL_USER_ID ? { ghl_user_id: process.env.GHL_USER_ID } : {},
8385
8717
  ...process.env.GHL_FIREBASE_API_KEY ? { ghl_firebase_api_key: process.env.GHL_FIREBASE_API_KEY } : {},
8386
- ...process.env.GHL_FIREBASE_REFRESH_TOKEN ? { ghl_firebase_refresh_token: process.env.GHL_FIREBASE_REFRESH_TOKEN } : {}
8718
+ ...process.env.GHL_FIREBASE_REFRESH_TOKEN ? { ghl_firebase_refresh_token: process.env.GHL_FIREBASE_REFRESH_TOKEN } : {},
8719
+ ...lic.signedAttestation ? { signed_attestation: lic.signedAttestation } : {}
8387
8720
  });
8388
8721
  process.stderr.write("[ghl-mcp] License validated and cached.\n");
8389
8722
  } catch {
@@ -8398,20 +8731,26 @@ async function resolveAccessAndRegister() {
8398
8731
  );
8399
8732
  }
8400
8733
  }
8734
+ if (licenseVerified) {
8735
+ process.stderr.write(`[ghl-mcp] License gate: ${attestationStatus}.
8736
+ `);
8737
+ }
8401
8738
  inBootstrapMode = !apiKey || !locationId || !licenseVerified;
8402
8739
  if (inBootstrapMode) {
8403
8740
  process.stderr.write(
8404
8741
  `[ghl-mcp] Bootstrap mode.
8405
8742
  [ghl-mcp] Need a valid license plus GHL_API_KEY + GHL_LOCATION_ID (env vars or credentials file at ${credentialsPath()}).
8406
- [ghl-mcp] Only setup_ghl_mcp, request_license, and get_mcp_version are available. Run setup_ghl_mcp with your license + GHL credentials \u2014 or request_license if you don't have a license yet.
8743
+ [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.
8407
8744
  `
8408
8745
  );
8409
8746
  registerSetupTool(server);
8410
8747
  registerLeadCaptureTool(server);
8748
+ registerFirebaseCaptureScriptTool(server);
8411
8749
  } else {
8412
8750
  const client = new GHLClient({ apiKey, locationId });
8413
8751
  registerAllTools(server, client, registry, pkg.version);
8414
8752
  registerEnableWorkflowBuilderTool(server);
8753
+ registerFirebaseCaptureScriptTool(server);
8415
8754
  if (fileCreds && !process.env.GHL_API_KEY) {
8416
8755
  process.stderr.write(`[ghl-mcp] Loaded credentials from ${credentialsPath()}
8417
8756
  `);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@elitedcs/ghl-mcp",
3
- "version": "3.23.0",
3
+ "version": "3.25.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",