@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.
- package/CHANGELOG.md +76 -0
- package/dist/index.js +229 -26
- 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.
|
|
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(
|
|
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
|
|
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
|
-
|
|
5537
|
-
if (
|
|
5538
|
-
|
|
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",
|
|
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 {
|
|
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
|
-
|
|
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 =
|
|
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.
|
|
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",
|