@elitedcs/ghl-mcp 3.20.0 → 3.24.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 +76 -0
  2. package/dist/index.js +229 -26
  3. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -1,5 +1,81 @@
1
1
  # Changelog
2
2
 
3
+ ## 3.24.0 — Signed-attestation gate closes the credentials.json bypass
4
+
5
+ **Security fix. Tool count unchanged (212 across 43 modules).**
6
+
7
+ 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.
8
+
9
+ **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:
10
+
11
+ - **Valid + fresh** → boot normally.
12
+ - **Valid + nearing expiry (<7d)** → boot normally, refresh in the background.
13
+ - **Expired but within 14-day grace window** → boot normally for this session, attempt re-renew (covers transient license-server outages).
14
+ - **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`.
15
+
16
+ **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.
17
+
18
+ **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.
19
+
20
+ **Pipeline automation shipped on the server side (no MCP impact).** A successful `validate-license` call now advances the buyer's GHL Command opportunity:
21
+
22
+ - Purchased → Onboarding on first install (installs_used 0 → 1).
23
+ - Onboarding → Active on second+ install or any reinstall.
24
+
25
+ Existing buyers backfilled manually based on their current `installs_used` count.
26
+
27
+ ## 3.23.0 — `update_calendar` can assign team members again (Henry fix, part 2)
28
+
29
+ **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.**
30
+
31
+ Reported by Henry Boulton, 2026-05-26.
32
+
33
+ **Root cause.** Same UI-vs-API pattern we hit in v3.22.0 (forms) and v3.15.0 (email templates). v3.21.0's diagnosis ("GHL silently drops `teamMembers` on PUT, no public-API path exists") was based on testing against an Event calendar — that calendar TYPE doesn't have team members, so GHL accepts the call and ignores the field. On round-robin and class-booking calendars the field is meaningful, and PUT validates it strictly: `priority` must be exactly `0`, `0.5`, or `1` (anything else 422s), `locationConfigurations` needs at least one entry, and `userId` / `selected` / `isPrimary` must be present. Henry's original attempt likely either targeted the wrong calendar type or passed a non-enum priority.
34
+
35
+ **Fix.** `teamMembers` is back on `update_calendar`, typed via an exported `CalendarTeamMemberSchema` that mirrors GHL's validation:
36
+
37
+ - `priority` constrained to `z.union([z.literal(0), z.literal(0.5), z.literal(1)])` — invalid values fail at the MCP layer with a clear error instead of an opaque GHL 422.
38
+ - `locationConfigurations` requires `min(1)` and a non-negative integer `position`.
39
+ - Tool description spells out that team-member assignment only applies to calendar types that support it (round_robin, class_booking); on Event calendars the field is silently ignored by GHL — that's the type, not us.
40
+
41
+ End-to-end verification 2026-05-26 against MCP Testing: create round_robin → assign user → update via PUT (priority 0.5 → 1, location string change) → GET back the new shape → cleanup.
42
+
43
+ Five new tests in `src/tools/tool-payloads.test.ts` lock the schema in.
44
+
45
+ ## 3.22.0 — `update_form` works again (Henry fix)
46
+
47
+ **Critical fix. Tool count unchanged (212 across 43 modules). The `update_form` tool, which v3.21.0 marked "currently unavailable," is back — wired to the live save endpoint and verified end-to-end against MCP Testing.**
48
+
49
+ Reported by Henry Boulton, 2026-05-26.
50
+
51
+ **Root cause.** v3.21.0's diagnosis was half right and half wrong. The half that was right: the v2 form builder UI does run in a cross-origin iframe (`leadgen-apps-form-survey-builder.leadconnectorhq.com`) and the UI does read form state through Firestore — top-frame network capture only sees a Firestore `Listen` channel, never a REST save. That's exactly what made the route look gone. The half that was wrong: we concluded the REST save route therefore *didn't exist* and the tool needed a Firestore-client integration. It does exist. The new UI just doesn't drive it. Same UI-vs-API divergence we saw with email templates in v3.15.0 ([reference_email_template_firestore](https://github.com/drjerryrelth/ghl-command-mcp/blob/main/CHANGELOG.md#3150)) — the REST routes survive even when the editor moves to Firestore.
52
+
53
+ **Fix.** `update_form` now calls `POST /forms/{formId}?locationId={loc}` with body exactly `{name, formData}`. Verified shape constraints (server rejects everything else with 422 "property X should not exist"):
54
+
55
+ - `locationId` belongs in the query string only — putting it in the body fails.
56
+ - `name` and `formData` are both required together — one without the other 422s.
57
+ - `_id`, `deleted`, `productType`, `dateAdded`, `dateUpdated`, `source`, `updatedBy`, `version`, `updatedAt`, `versionHistory` must not appear in the body — `get_form_full` → pass through fails, so callers should pass only the `formData` they want to write.
58
+
59
+ If `name` is omitted, the tool pre-fetches the current form (one extra GET) so the existing name is preserved. Body and path are factored into pure `buildUpdateFormBody` / `buildUpdateFormPath` helpers and locked in by `src/tools/tool-payloads.test.ts` (3 new tests) — a refactor can't silently regress the tool back to v3.21.0's "unavailable" state.
60
+
61
+ End-to-end verification 2026-05-26: read form (version 5) → mutate first field's `placeholder` → save (201) → re-read (placeholder persisted) → restore. MCP Testing sandbox left clean.
62
+
63
+ ## 3.21.0 — Workflow Builder now loads for npm-install buyers (Henry + Ryan fix)
64
+
65
+ **Critical fix. Tool count unchanged (212 across 43 modules), but for `npx`-install buyers the 49 Firebase-gated tools (workflow builder, funnel builder, form builder, pipeline builder, smart lists, reputation, email campaigns, memberships, email-builder internal, plus the pre-deploy validator) now actually register after `enable_workflow_builder` — previously they stayed "Not configured" no matter how many times you restarted.**
66
+
67
+ Reported independently by Henry Boulton and Ryan Thomas, 2026-05-26.
68
+
69
+ **Root cause.** Index startup folded `GHL_USER_ID` + the Firebase trio from `credentials.json` into `process.env`, but never folded `GHL_API_KEY` or `GHL_LOCATION_ID`. `WorkflowBuilderClient.fromEnv()` reads all five directly from `process.env`, so for the recommended `npx -y @elitedcs/ghl-mcp@latest` install path — where no env vars are set anywhere — `fromEnv()` returned `null` and every Firebase-gated tool was skipped. Wrapper-script installs (e.g. `start-mcp.sh`) were unaffected because they exported all the vars manually. Henry's workaround of passing the values as `--env` flags on `claude mcp add` "fixed" it by route, which is why this looked like a credentials-file persistence bug.
70
+
71
+ **Fix.** All credentials-file values now fold into `process.env` at startup (env still wins when already set, so wrapper-script + headless paths are unchanged). Pulled the fold into a single `foldCredentialsIntoEnv()` helper with regression tests that lock in all five Firebase-gated fields.
72
+
73
+ Also in this release:
74
+
75
+ - **`create_form`** now uses the correct path. GHL silently retired `POST /forms?locationId=…` (returns 404); the working path is `POST /forms/?locationId=…` (trailing slash). Verified against MCP Testing.
76
+ - **`update_form`** is honestly marked unavailable, with the actual root cause documented for the first time: the GHL form builder runs in a cross-origin iframe (`leadgen-apps-form-survey-builder.leadconnectorhq.com`) and writes saves **directly to Firestore via the Firebase JS SDK** — `firestore.googleapis.com/.../databases/(default)` against the `highlevel-backend` project — rather than any REST endpoint. That's why every REST probe returns 404 or "Form does not exist or is deleted." Wiring this up needs a Firestore-client integration (separate from the workflow-builder's REST-over-Firebase-token pattern). Tracked for a focused future release. Workaround: `delete_form` + `create_form` (both work).
77
+ - **`update_calendar`** removed `teamMembers` from the input. GHL's public API silently drops the field on PUT (200 OK, nothing persists) across every shape and API version we tested. The tool description now tells the caller to set the assignee in the GHL UI instead of returning a fake success.
78
+
3
79
  ## 3.20.0 — License gate for headless / env-var installs
4
80
 
5
81
  **Security + headless support. No tool changes (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.20.0",
34
+ version: "3.24.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();
@@ -332,6 +339,23 @@ function readCredentials() {
332
339
  return null;
333
340
  }
334
341
  }
342
+ function foldCredentialsIntoEnv(creds, env = process.env) {
343
+ if (!creds) return;
344
+ const mappings = [
345
+ ["ghl_api_key", "GHL_API_KEY"],
346
+ ["ghl_location_id", "GHL_LOCATION_ID"],
347
+ ["ghl_user_id", "GHL_USER_ID"],
348
+ ["ghl_firebase_api_key", "GHL_FIREBASE_API_KEY"],
349
+ ["ghl_firebase_refresh_token", "GHL_FIREBASE_REFRESH_TOKEN"],
350
+ ["ghl_company_id", "GHL_COMPANY_ID"]
351
+ ];
352
+ for (const [credKey, envKey] of mappings) {
353
+ const value = creds[credKey];
354
+ if (value && !env[envKey]) {
355
+ env[envKey] = value;
356
+ }
357
+ }
358
+ }
335
359
  function writeCredentials(creds) {
336
360
  ensureAppDataDir();
337
361
  const file = credentialsPath();
@@ -2370,6 +2394,18 @@ function registerOpportunityTools(server2, client) {
2370
2394
 
2371
2395
  // src/tools/calendars.ts
2372
2396
  var import_zod9 = require("zod");
2397
+ var TeamMemberLocationConfigSchema = import_zod9.z.object({
2398
+ kind: import_zod9.z.string().describe("Meeting location kind. Common values: 'custom', 'zoom_conference', 'google_meet'."),
2399
+ position: import_zod9.z.number().int().nonnegative().describe("Position index for ordering (0-based)."),
2400
+ location: import_zod9.z.string().optional().describe("Display string for the meeting location (URL or address). Optional for kind='custom'.")
2401
+ });
2402
+ var CalendarTeamMemberSchema = import_zod9.z.object({
2403
+ userId: import_zod9.z.string().describe("GHL user ID to assign. Must exist in this sub-account."),
2404
+ 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
+ isPrimary: import_zod9.z.boolean().describe("Whether this user is the primary assignee."),
2406
+ selected: import_zod9.z.boolean().describe("Whether this user is currently active on the calendar (true to enable)."),
2407
+ locationConfigurations: import_zod9.z.array(TeamMemberLocationConfigSchema).min(1).describe("At least one meeting-location config. Typical shape: [{kind:'custom', position:0, location:''}].")
2408
+ });
2373
2409
  function registerCalendarTools(server2, client) {
2374
2410
  safeTool(
2375
2411
  server2,
@@ -2439,7 +2475,7 @@ function registerCalendarTools(server2, client) {
2439
2475
  safeTool(
2440
2476
  server2,
2441
2477
  "update_calendar",
2442
- "Update an existing calendar",
2478
+ "Update an existing calendar's settings. Supports name, description, slug, event title/color, slot durations, capacity, AND team-member assignment via `teamMembers`. Team members only apply to calendar types that use them (round_robin, class_booking, etc.) \u2014 on event calendars GHL accepts the call but silently ignores the field. priority MUST be 0, 0.5, or 1.",
2443
2479
  {
2444
2480
  calendarId: import_zod9.z.string().describe("The calendar ID to update"),
2445
2481
  name: import_zod9.z.string().optional().describe("Calendar name"),
@@ -2447,7 +2483,7 @@ function registerCalendarTools(server2, client) {
2447
2483
  slug: import_zod9.z.string().optional().describe("Calendar slug"),
2448
2484
  widgetSlug: import_zod9.z.string().optional().describe("Widget slug for embedding"),
2449
2485
  calendarType: import_zod9.z.string().optional().describe("Calendar type"),
2450
- teamMembers: import_zod9.z.array(import_zod9.z.record(import_zod9.z.unknown())).optional().describe("Array of team member objects"),
2486
+ teamMembers: import_zod9.z.array(CalendarTeamMemberSchema).optional().describe("Replace the calendar's team-member assignments. Pass [] to clear (round-robin calendars require at least one). Use get_calendar to inspect the current shape first."),
2451
2487
  eventTitle: import_zod9.z.string().optional().describe("Default event title"),
2452
2488
  eventColor: import_zod9.z.string().optional().describe("Event color hex code"),
2453
2489
  slotDuration: import_zod9.z.number().optional().describe("Slot duration in minutes"),
@@ -5482,6 +5518,12 @@ ${text2}`);
5482
5518
 
5483
5519
  // src/tools/form-builder.ts
5484
5520
  var import_zod35 = require("zod");
5521
+ function buildUpdateFormPath(formId, locationId2) {
5522
+ return `/${formId}?locationId=${locationId2}`;
5523
+ }
5524
+ function buildUpdateFormBody(name, formData) {
5525
+ return { name, formData };
5526
+ }
5485
5527
  function registerFormBuilderTools(server2, builderClient) {
5486
5528
  const client = builderClient;
5487
5529
  if (!client) return;
@@ -5525,17 +5567,28 @@ ${text2}`);
5525
5567
  );
5526
5568
  server2.tool(
5527
5569
  "update_form",
5528
- "Update a form's structure \u2014 fields, labels, conditional logic, auto-responder, email notifications, styling. Use get_form_full first to see the current structure, modify the formData object, and pass it here.",
5570
+ "Update a form's structure \u2014 fields, labels, conditional logic, auto-responder, email notifications, styling. Use get_form_full first to read the current form, modify the formData object, then pass it here. If name is omitted, the current name is preserved (one extra GET).",
5529
5571
  {
5530
5572
  formId: import_zod35.z.string().describe("The form ID to update."),
5531
5573
  formData: import_zod35.z.record(import_zod35.z.unknown()).describe("The updated formData object containing form fields, settings, autoResponder config, etc."),
5532
- name: import_zod35.z.string().optional().describe("Updated form name.")
5574
+ name: import_zod35.z.string().optional().describe("Updated form name. If omitted, the current name is preserved.")
5533
5575
  },
5534
5576
  async ({ formId, formData, name }) => {
5535
5577
  try {
5536
- const body = { formData, locationId: client.locationId };
5537
- if (name !== void 0) body.name = name;
5538
- const result = await formRequest("PUT", `/${formId}?locationId=${client.locationId}`, body);
5578
+ let resolvedName = name;
5579
+ if (resolvedName === void 0) {
5580
+ const current = await formRequest("GET", `/${formId}?locationId=${client.locationId}`);
5581
+ const currentName = typeof current === "object" && current !== null && "form" in current ? current.form?.name : void 0;
5582
+ if (typeof currentName !== "string") {
5583
+ throw new Error(`Could not resolve current form name for ${formId} (GET /forms/${formId} returned no string name).`);
5584
+ }
5585
+ resolvedName = currentName;
5586
+ }
5587
+ const result = await formRequest(
5588
+ "POST",
5589
+ buildUpdateFormPath(formId, client.locationId),
5590
+ buildUpdateFormBody(resolvedName, formData)
5591
+ );
5539
5592
  return {
5540
5593
  content: [{ type: "text", text: JSON.stringify(result, null, 2) }]
5541
5594
  };
@@ -5552,7 +5605,7 @@ ${text2}`);
5552
5605
  },
5553
5606
  async ({ name }) => {
5554
5607
  try {
5555
- const result = await formRequest("POST", `?locationId=${client.locationId}`, {
5608
+ const result = await formRequest("POST", `/?locationId=${client.locationId}`, {
5556
5609
  name,
5557
5610
  locationId: client.locationId,
5558
5611
  formData: {
@@ -5797,7 +5850,11 @@ async function validateLicense(email, licenseKey) {
5797
5850
  });
5798
5851
  const data = await res.json().catch(() => ({}));
5799
5852
  if (res.ok && data.valid) {
5800
- return { ok: true, installs: `${data.installs_used}/${data.installs_max}` };
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
+ };
5801
5858
  }
5802
5859
  return { ok: false, error: data.error || data.message || `License validation failed (HTTP ${res.status})` };
5803
5860
  } catch (err) {
@@ -5897,7 +5954,10 @@ Note: Firebase credentials rejected (${fb.error}). Saved without Workflow Builde
5897
5954
  ghl_company_id: args.ghl_company_id?.trim() || void 0,
5898
5955
  ghl_user_id: workflowBuilderEnabled ? args.ghl_user_id?.trim() : void 0,
5899
5956
  ghl_firebase_api_key: workflowBuilderEnabled ? args.ghl_firebase_api_key?.trim() : void 0,
5900
- ghl_firebase_refresh_token: workflowBuilderEnabled ? args.ghl_firebase_refresh_token?.trim() : void 0
5957
+ ghl_firebase_refresh_token: workflowBuilderEnabled ? args.ghl_firebase_refresh_token?.trim() : void 0,
5958
+ // v3.24.0+ attestation. Tied to email + license + device fingerprint;
5959
+ // verified on every MCP startup. Closes the hand-crafted creds bypass.
5960
+ signed_attestation: lic.signedAttestation
5901
5961
  });
5902
5962
  const toolCount = workflowBuilderEnabled ? "212" : "163";
5903
5963
  const wfLine = workflowBuilderEnabled ? "Workflow Builder: enabled." : "Workflow Builder: not configured (optional).";
@@ -8236,6 +8296,92 @@ function registerAllTools(server2, client, registry2, mcpVersion) {
8236
8296
  registerLocationSwitcherTools(server2, client, builderClient, registry2, mcpVersion);
8237
8297
  }
8238
8298
 
8299
+ // src/attestation.ts
8300
+ var import_node_crypto = require("node:crypto");
8301
+ var ATTESTATION_PUBLIC_KEY_B64 = "8KJo8V3Z7ngeToIQfOXpKdlr/EA34hXJVEIpW0A7wqs=";
8302
+ var ATTESTATION_VERSION = 1;
8303
+ var ATTESTATION_GRACE_DAYS = 14;
8304
+ function deviceFingerprint2() {
8305
+ const os3 = require("node:os");
8306
+ const crypto4 = require("node:crypto");
8307
+ const raw = `${os3.hostname()}:${os3.userInfo().username}:${os3.platform()}:${os3.arch()}`;
8308
+ return crypto4.createHash("sha256").update(raw).digest("hex").slice(0, 16);
8309
+ }
8310
+ function base64urlDecode(s) {
8311
+ const b64 = s.replace(/-/g, "+").replace(/_/g, "/");
8312
+ const padded = b64 + "=".repeat((4 - b64.length % 4) % 4);
8313
+ return new Uint8Array(Buffer.from(padded, "base64"));
8314
+ }
8315
+ var cachedPublicKey = null;
8316
+ async function getPublicKey() {
8317
+ if (cachedPublicKey) return cachedPublicKey;
8318
+ const raw = Buffer.from(ATTESTATION_PUBLIC_KEY_B64, "base64");
8319
+ cachedPublicKey = await import_node_crypto.webcrypto.subtle.importKey(
8320
+ "raw",
8321
+ raw,
8322
+ { name: "Ed25519" },
8323
+ false,
8324
+ ["verify"]
8325
+ );
8326
+ return cachedPublicKey;
8327
+ }
8328
+ async function verifyAttestation(token, bindings, now = /* @__PURE__ */ new Date()) {
8329
+ if (typeof token !== "string" || !token) return { ok: false, reason: "malformed" };
8330
+ const dot = token.indexOf(".");
8331
+ if (dot <= 0 || dot === token.length - 1) return { ok: false, reason: "malformed" };
8332
+ let payloadBytes;
8333
+ let signatureBytes;
8334
+ try {
8335
+ payloadBytes = base64urlDecode(token.slice(0, dot));
8336
+ signatureBytes = base64urlDecode(token.slice(dot + 1));
8337
+ } catch {
8338
+ return { ok: false, reason: "malformed" };
8339
+ }
8340
+ let payload;
8341
+ try {
8342
+ const parsed = JSON.parse(new TextDecoder().decode(payloadBytes));
8343
+ if (typeof parsed.v !== "number") return { ok: false, reason: "malformed" };
8344
+ if (parsed.v !== ATTESTATION_VERSION) return { ok: false, reason: "unsupported-version" };
8345
+ payload = parsed;
8346
+ } catch {
8347
+ return { ok: false, reason: "malformed" };
8348
+ }
8349
+ let signatureValid;
8350
+ try {
8351
+ const key = await getPublicKey();
8352
+ signatureValid = await import_node_crypto.webcrypto.subtle.verify({ name: "Ed25519" }, key, signatureBytes, payloadBytes);
8353
+ } catch {
8354
+ return { ok: false, reason: "bad-signature" };
8355
+ }
8356
+ if (!signatureValid) return { ok: false, reason: "bad-signature" };
8357
+ if (payload.email.toLowerCase() !== bindings.email.toLowerCase()) {
8358
+ return { ok: false, reason: "wrong-email" };
8359
+ }
8360
+ if (payload.license_key !== bindings.license_key) {
8361
+ return { ok: false, reason: "wrong-license" };
8362
+ }
8363
+ const fingerprint = bindings.expectedFingerprint ?? deviceFingerprint2();
8364
+ if (payload.device_fingerprint !== fingerprint) {
8365
+ return { ok: false, reason: "wrong-device" };
8366
+ }
8367
+ const expiry = Date.parse(payload.expires_at);
8368
+ if (!Number.isFinite(expiry)) return { ok: false, reason: "malformed" };
8369
+ const graceDeadline = expiry + ATTESTATION_GRACE_DAYS * 24 * 60 * 60 * 1e3;
8370
+ if (now.getTime() <= expiry) {
8371
+ return { ok: true, payload, reason: "valid" };
8372
+ }
8373
+ if (now.getTime() <= graceDeadline) {
8374
+ return { ok: true, payload, reason: "in-grace" };
8375
+ }
8376
+ return { ok: false, reason: "expired" };
8377
+ }
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
+
8239
8385
  // src/tools/meta.ts
8240
8386
  function registerMetaTools(server2, installedVersion) {
8241
8387
  server2.tool(
@@ -8303,18 +8449,7 @@ var registry = new TokenRegistry();
8303
8449
  var fileCreds = readCredentials();
8304
8450
  var locationId = process.env.GHL_LOCATION_ID || fileCreds?.ghl_location_id || void 0;
8305
8451
  var apiKey = process.env.GHL_API_KEY || fileCreds?.ghl_api_key || void 0;
8306
- if (fileCreds?.ghl_user_id && !process.env.GHL_USER_ID) {
8307
- process.env.GHL_USER_ID = fileCreds.ghl_user_id;
8308
- }
8309
- if (fileCreds?.ghl_firebase_api_key && !process.env.GHL_FIREBASE_API_KEY) {
8310
- process.env.GHL_FIREBASE_API_KEY = fileCreds.ghl_firebase_api_key;
8311
- }
8312
- if (fileCreds?.ghl_firebase_refresh_token && !process.env.GHL_FIREBASE_REFRESH_TOKEN) {
8313
- process.env.GHL_FIREBASE_REFRESH_TOKEN = fileCreds.ghl_firebase_refresh_token;
8314
- }
8315
- if (fileCreds?.ghl_company_id && !process.env.GHL_COMPANY_ID) {
8316
- process.env.GHL_COMPANY_ID = fileCreds.ghl_company_id;
8317
- }
8452
+ foldCredentialsIntoEnv(fileCreds);
8318
8453
  if (locationId && registry.hasTokens()) {
8319
8454
  const token = registry.getToken(locationId);
8320
8455
  if (token) {
@@ -8329,8 +8464,70 @@ var server = new import_mcp.McpServer({
8329
8464
  });
8330
8465
  registerMetaTools(server, pkg.version);
8331
8466
  var inBootstrapMode = true;
8467
+ async function renewAttestation(creds) {
8468
+ if (!creds.email || !creds.license_key) return false;
8469
+ try {
8470
+ const lic = await validateLicense(creds.email, creds.license_key);
8471
+ if (!lic.ok) {
8472
+ process.stderr.write(`[ghl-mcp] Re-validate failed: ${lic.error}
8473
+ `);
8474
+ return false;
8475
+ }
8476
+ try {
8477
+ writeCredentials({
8478
+ ...creds,
8479
+ verified_at: (/* @__PURE__ */ new Date()).toISOString(),
8480
+ ...lic.signedAttestation ? { signed_attestation: lic.signedAttestation } : {}
8481
+ });
8482
+ } catch {
8483
+ }
8484
+ if (!lic.signedAttestation) {
8485
+ 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");
8486
+ }
8487
+ return true;
8488
+ } catch {
8489
+ return false;
8490
+ }
8491
+ }
8492
+ function renewAttestationInBackground(creds) {
8493
+ return renewAttestation(creds).then(() => void 0, () => void 0);
8494
+ }
8332
8495
  async function resolveAccessAndRegister() {
8333
- let licenseVerified = Boolean(fileCreds?.verified_at && fileCreds?.license_key);
8496
+ let licenseVerified = false;
8497
+ let attestationStatus = "no-creds";
8498
+ if (fileCreds?.email && fileCreds?.license_key && fileCreds.signed_attestation) {
8499
+ const verify = await verifyAttestation(fileCreds.signed_attestation, {
8500
+ email: fileCreds.email,
8501
+ license_key: fileCreds.license_key
8502
+ });
8503
+ if (verify.ok) {
8504
+ licenseVerified = true;
8505
+ attestationStatus = verify.reason;
8506
+ if (verify.reason === "in-grace" || shouldRenew(verify.payload)) {
8507
+ void renewAttestationInBackground(fileCreds);
8508
+ }
8509
+ } else {
8510
+ process.stderr.write(`[ghl-mcp] Stored attestation rejected: ${verify.reason}. Re-validating online.
8511
+ `);
8512
+ const renewed = await renewAttestation(fileCreds);
8513
+ if (renewed) {
8514
+ licenseVerified = true;
8515
+ attestationStatus = "renewed-after-tamper";
8516
+ } else {
8517
+ attestationStatus = "needs-reset";
8518
+ }
8519
+ }
8520
+ } else if (fileCreds?.email && fileCreds?.license_key) {
8521
+ process.stderr.write("[ghl-mcp] Upgrading legacy credentials.json to signed attestation.\n");
8522
+ const renewed = await renewAttestation(fileCreds);
8523
+ if (renewed) {
8524
+ licenseVerified = true;
8525
+ attestationStatus = "renewed-from-legacy";
8526
+ } else {
8527
+ attestationStatus = "needs-reset";
8528
+ process.stderr.write("[ghl-mcp] Could not re-validate. Run setup_ghl_mcp again.\n");
8529
+ }
8530
+ }
8334
8531
  if (!licenseVerified && apiKey && locationId) {
8335
8532
  const licEmail = (process.env.GHL_LICENSE_EMAIL || fileCreds?.email)?.trim();
8336
8533
  const licKey = (process.env.GHL_LICENSE_KEY || fileCreds?.license_key)?.trim();
@@ -8338,6 +8535,7 @@ async function resolveAccessAndRegister() {
8338
8535
  const lic = await validateLicense(licEmail, licKey);
8339
8536
  if (lic.ok) {
8340
8537
  licenseVerified = true;
8538
+ attestationStatus = "renewed";
8341
8539
  try {
8342
8540
  writeCredentials({
8343
8541
  license_key: licKey,
@@ -8348,7 +8546,8 @@ async function resolveAccessAndRegister() {
8348
8546
  ...process.env.GHL_COMPANY_ID ? { ghl_company_id: process.env.GHL_COMPANY_ID } : {},
8349
8547
  ...process.env.GHL_USER_ID ? { ghl_user_id: process.env.GHL_USER_ID } : {},
8350
8548
  ...process.env.GHL_FIREBASE_API_KEY ? { ghl_firebase_api_key: process.env.GHL_FIREBASE_API_KEY } : {},
8351
- ...process.env.GHL_FIREBASE_REFRESH_TOKEN ? { ghl_firebase_refresh_token: process.env.GHL_FIREBASE_REFRESH_TOKEN } : {}
8549
+ ...process.env.GHL_FIREBASE_REFRESH_TOKEN ? { ghl_firebase_refresh_token: process.env.GHL_FIREBASE_REFRESH_TOKEN } : {},
8550
+ ...lic.signedAttestation ? { signed_attestation: lic.signedAttestation } : {}
8352
8551
  });
8353
8552
  process.stderr.write("[ghl-mcp] License validated and cached.\n");
8354
8553
  } catch {
@@ -8363,6 +8562,10 @@ async function resolveAccessAndRegister() {
8363
8562
  );
8364
8563
  }
8365
8564
  }
8565
+ if (licenseVerified) {
8566
+ process.stderr.write(`[ghl-mcp] License gate: ${attestationStatus}.
8567
+ `);
8568
+ }
8366
8569
  inBootstrapMode = !apiKey || !locationId || !licenseVerified;
8367
8570
  if (inBootstrapMode) {
8368
8571
  process.stderr.write(
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@elitedcs/ghl-mcp",
3
- "version": "3.20.0",
3
+ "version": "3.24.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",