@elitedcs/ghl-mcp 3.16.1 → 3.17.1

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 CHANGED
@@ -1,5 +1,32 @@
1
1
  # Changelog
2
2
 
3
+ ## 3.17.1 — Onboarding messaging fixes + public feedback tracker
4
+
5
+ **No tool changes — still 212 across 43 modules.** Bug-driven fixes from a buyer support ticket (Ryan Thomas, 2026-05-25), plus a public place to file feedback.
6
+
7
+ ### Firebase / Workflow Builder onboarding was steering buyers wrong
8
+
9
+ `workflow_builder_status` told users to "set env vars in start-mcp.sh or .env" to enable the 49 Firebase-gated tools. On the npm / Claude Desktop install path there is no `start-mcp.sh` or `.env`, so buyers added the Firebase values to the `claude_desktop_config.json` env block — which Claude Desktop passes unreliably — and got `health_check` → "Firebase: SKIP" after a restart. Now both `workflow_builder_status` and the `health_check` Firebase-SKIP detail steer to `enable_workflow_builder` (the supported path: it validates the values and writes them to `credentials.json`), explicitly warn off the config env-var path, and demote the raw env vars to an advanced/self-hosted note.
10
+
11
+ ### Dead npm "Repository" link → public feedback repo
12
+
13
+ `package.json` `repository`/`bugs` pointed at the **private** source repo, which npm renders as a clickable link that 404s for buyers. Now both point to the new public feedback tracker — **https://github.com/drjerryrelth/ghl-command-feedback** — where users can file bug reports and feature requests via issue templates.
14
+
15
+ ## 3.17.0 — Multi-tenant bootstrap, internal hardening, test coverage
16
+
17
+ **No new tools — still 212 across 43 modules. Internal hardening + a multi-tenant fix.**
18
+
19
+ ### Multi-tenant: bootstrap from a client company's Firebase
20
+
21
+ A multi-tenant-only install (an agency operating purely in clients' accounts, no home Firebase) previously got **none** of the Firebase-gated tools: `WorkflowBuilderClient.fromEnv()` returned null, so every gated module skipped registration even after `register_company_firebase`. Now, when there's no home Firebase, the builder client bootstraps from the first registered company that has both Firebase creds and a registered location (`fromFirstCompany`). `switch_location` still routes to other companies afterward. Token rotation on a bootstrapped install now persists back to that company's `firebaseByCompany` slot instead of being dropped (the same class of bug Don Harris hit with home-token persistence).
22
+
23
+ ### Internal hardening
24
+
25
+ - **`safeTool()` consistency.** Migrated the Firebase-gated modules (memberships, reputation, email campaigns, email-template/snippet internal tools) from hand-rolled `try/catch` + `jsonResponse` to the `safeTool()` wrapper, matching the public-API tools and the type-safety standard.
26
+ - **Extracted request-body builders** (`buildCoursePayload`, `buildCategoryPayload`, `buildLessonPayload`, `buildOfferPayload`, `buildSnippetPayload`, `buildReviewsQuery`) as pure, exported functions.
27
+ - **+16 unit tests** (101 → 117) locking in the v3.16.0 request shapes and the nested-`filterParams` reviews query, plus the new multi-tenant bootstrap + its rotation persistence.
28
+ - **Doc count drift:** fixed remaining stale "8 / 30 / 168" tool counts in `setup_ghl_mcp`, `health_check`, and the README tool table (now consistently 163 base / 49 Firebase-gated / 212 total).
29
+
3
30
  ## 3.16.1 — Docs: correct enable_workflow_builder tool counts
4
31
 
5
32
  **No tool changes. Still 212 tools across 43 modules (163 without Firebase, 49 Firebase-gated).**
package/README.md CHANGED
@@ -451,7 +451,7 @@ To unlock full builder access across multiple clients from one install:
451
451
  |---|---|
452
452
  | `get_mcp_version` | Check installed version against the latest published to npm. Confirms an upgrade landed after restarting Claude. Available even before GHL credentials are configured. |
453
453
  | `health_check` | Run a full health check: npm registry + version status, GHL API key validity, default location reachability, Firebase auth status, token registry presence. Use when something feels broken. |
454
- | `enable_workflow_builder` | Add Firebase credentials to an existing install to unlock 30 additional tools across 6 modules (workflow builder, funnel builder, form builder, pipeline builder, workflow cloner, validate_workflow). No need to re-enter license / API key / location ID. Run this any time after the basic setup, on the buyer's schedule. |
454
+ | `enable_workflow_builder` | Add Firebase credentials to an existing install to unlock 49 additional Firebase-gated tools (workflow builder, funnel builder, form builder, pipeline builder, workflow cloner, smart lists, reputation, email campaigns, email templates, memberships, validate_workflow). No need to re-enter license / API key / location ID. Run this any time after the basic setup, on the buyer's schedule. |
455
455
 
456
456
  ### Other Modules
457
457
 
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.16.1",
34
+ version: "3.17.1",
35
35
  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.",
36
36
  main: "dist/index.js",
37
37
  bin: {
@@ -68,10 +68,11 @@ var require_package = __commonJS({
68
68
  homepage: "https://elitedcs.com/ghl-mcp-server",
69
69
  repository: {
70
70
  type: "git",
71
- url: "git+https://github.com/drjerryrelth/elite-dcs-ghl-mcp.git"
71
+ url: "git+https://github.com/drjerryrelth/ghl-command-feedback.git"
72
72
  },
73
73
  bugs: {
74
- url: "https://github.com/drjerryrelth/elite-dcs-ghl-mcp/issues"
74
+ url: "https://github.com/drjerryrelth/ghl-command-feedback/issues",
75
+ email: "support@cliniclaunchlab.com"
75
76
  },
76
77
  publishConfig: {
77
78
  access: "public"
@@ -514,6 +515,19 @@ var TokenRegistry = class {
514
515
  name: token.name
515
516
  }));
516
517
  }
518
+ /**
519
+ * Find the first registered location belonging to a company. Used to
520
+ * bootstrap the workflow-builder client from a company Firebase entry when
521
+ * the install has no home Firebase (multi-tenant-only installs).
522
+ */
523
+ firstLocationForCompany(companyId) {
524
+ for (const [locationId2, token] of Object.entries(this.data.tokens)) {
525
+ if (token.companyId === companyId) {
526
+ return { locationId: locationId2, apiKey: token.apiKey };
527
+ }
528
+ }
529
+ return void 0;
530
+ }
517
531
  /**
518
532
  * Update the home Firebase refresh token (called on rotation)
519
533
  */
@@ -1226,6 +1240,37 @@ var WorkflowBuilderClient = class _WorkflowBuilderClient {
1226
1240
  registry: registry2
1227
1241
  });
1228
1242
  }
1243
+ /**
1244
+ * Bootstrap from a registered CLIENT company's Firebase when there is no home
1245
+ * Firebase (multi-tenant-only install — e.g. an agency operating purely in
1246
+ * clients' accounts). Without this, fromEnv() returns null and every
1247
+ * Firebase-gated tool fails to register even after register_company_firebase.
1248
+ *
1249
+ * Picks the first company that has both Firebase creds and a registered
1250
+ * location, and makes that company the active (and "home") slot. switch_location
1251
+ * can still route to other companies afterward. Returns null if no such pair
1252
+ * exists. The chosen company is treated as home, so token rotation persists
1253
+ * back to its firebaseByCompany slot (see persistRotatedToken).
1254
+ */
1255
+ static fromFirstCompany(registry2) {
1256
+ if (!registry2) return null;
1257
+ for (const { companyId } of registry2.listCompanyFirebases()) {
1258
+ const fb = registry2.getCompanyFirebase(companyId);
1259
+ const loc = registry2.firstLocationForCompany(companyId);
1260
+ if (fb && loc) {
1261
+ return new _WorkflowBuilderClient({
1262
+ firebaseApiKey: fb.apiKey,
1263
+ refreshToken: fb.refreshToken,
1264
+ apiKey: loc.apiKey,
1265
+ locationId: loc.locationId,
1266
+ userId: fb.userId,
1267
+ companyId,
1268
+ registry: registry2
1269
+ });
1270
+ }
1271
+ }
1272
+ return null;
1273
+ }
1229
1274
  /**
1230
1275
  * Get a fresh Firebase ID token using the refresh token.
1231
1276
  * Uses a promise lock to prevent concurrent refresh races.
@@ -1278,9 +1323,12 @@ var WorkflowBuilderClient = class _WorkflowBuilderClient {
1278
1323
  */
1279
1324
  persistRotatedToken(newToken) {
1280
1325
  const company = this.currentCompanyId;
1281
- const isClientCompany = company !== void 0 && company !== this.homeCompanyId && this.registry?.getCompanyFirebase(company) !== void 0;
1282
- if (isClientCompany) {
1326
+ const hasCompanySlot = company !== void 0 && this.registry?.getCompanyFirebase(company) !== void 0;
1327
+ if (hasCompanySlot) {
1283
1328
  this.registry?.updateCompanyFirebaseRefreshToken(company, newToken);
1329
+ if (company === this.homeCompanyId) {
1330
+ this.homeRefreshToken = newToken;
1331
+ }
1284
1332
  return;
1285
1333
  }
1286
1334
  if (company === void 0 || company === this.homeCompanyId) {
@@ -3972,7 +4020,8 @@ ${text2}`);
3972
4020
  const text = await response.text();
3973
4021
  return text ? JSON.parse(text) : { ok: true };
3974
4022
  }
3975
- server2.tool(
4023
+ safeTool(
4024
+ server2,
3976
4025
  "delete_email_template",
3977
4026
  "Permanently delete an email template ('builder') by id. This is a HARD delete \u2014 the template is removed, not archived (use archive_email_template if you want a reversible remove). Requires Firebase auth; the public bearer API can't do this. Get the templateId from list_email_templates. WARNING: irreversible. If the template is referenced by a workflow email action or a draft campaign, deleting it will break that reference.",
3978
4027
  {
@@ -3980,16 +4029,12 @@ ${text2}`);
3980
4029
  locationId: import_zod25.z.string().optional().describe("Location ID. Falls back to the active builder client's location.")
3981
4030
  },
3982
4031
  async ({ templateId, locationId: locationId2 }) => {
3983
- try {
3984
- const loc = locationId2 ?? client.locationId;
3985
- const result = await builderRequest("DELETE", `/data/${templateId}?locationId=${loc}`);
3986
- return jsonResponse(result);
3987
- } catch (error) {
3988
- return errorResponse(error);
3989
- }
4032
+ const loc = locationId2 ?? client.locationId;
4033
+ return builderRequest("DELETE", `/data/${templateId}?locationId=${loc}`);
3990
4034
  }
3991
4035
  );
3992
- server2.tool(
4036
+ safeTool(
4037
+ server2,
3993
4038
  "rename_email_template",
3994
4039
  "Rename an existing email template ('builder'). Updates the display title only; the HTML content and sender settings are left untouched. Requires Firebase auth; the public bearer API can't do this. Get the templateId from list_email_templates.",
3995
4040
  {
@@ -3998,16 +4043,12 @@ ${text2}`);
3998
4043
  locationId: import_zod25.z.string().optional().describe("Location ID. Falls back to the active builder client's location.")
3999
4044
  },
4000
4045
  async ({ templateId, name, locationId: locationId2 }) => {
4001
- try {
4002
- const loc = locationId2 ?? client.locationId;
4003
- const result = await builderRequest("PATCH", `/${templateId}`, { locationId: loc, name });
4004
- return jsonResponse(result);
4005
- } catch (error) {
4006
- return errorResponse(error);
4007
- }
4046
+ const loc = locationId2 ?? client.locationId;
4047
+ return builderRequest("PATCH", `/${templateId}`, { locationId: loc, name });
4008
4048
  }
4009
4049
  );
4010
- server2.tool(
4050
+ safeTool(
4051
+ server2,
4011
4052
  "archive_email_template",
4012
4053
  "Archive (or unarchive) an email template ('builder'). Archiving removes it from the active templates list without deleting it \u2014 a reversible alternative to delete_email_template. Requires Firebase auth. Get the templateId from list_email_templates.",
4013
4054
  {
@@ -4016,16 +4057,12 @@ ${text2}`);
4016
4057
  locationId: import_zod25.z.string().optional().describe("Location ID. Falls back to the active builder client's location.")
4017
4058
  },
4018
4059
  async ({ templateId, archived, locationId: locationId2 }) => {
4019
- try {
4020
- const loc = locationId2 ?? client.locationId;
4021
- const result = await builderRequest("PATCH", `/${templateId}`, { locationId: loc, archived: archived ?? true });
4022
- return jsonResponse(result);
4023
- } catch (error) {
4024
- return errorResponse(error);
4025
- }
4060
+ const loc = locationId2 ?? client.locationId;
4061
+ return builderRequest("PATCH", `/${templateId}`, { locationId: loc, archived: archived ?? true });
4026
4062
  }
4027
4063
  );
4028
- server2.tool(
4064
+ safeTool(
4065
+ server2,
4029
4066
  "create_sms_template",
4030
4067
  "Create an SMS/text template (a 'snippet') for quick-insert into the conversations composer and SMS workflow actions. The body can include merge fields like {{contact.first_name}}. Set type to 'email' to create an HTML email snippet instead. Returns the created snippet including its id. Requires Firebase auth \u2014 the public bearer key returns 401 on this endpoint.",
4031
4068
  {
@@ -4035,35 +4072,34 @@ ${text2}`);
4035
4072
  locationId: import_zod25.z.string().optional().describe("Location ID. Falls back to the active builder client's location.")
4036
4073
  },
4037
4074
  async ({ name, body, type, locationId: locationId2 }) => {
4038
- try {
4039
- const loc = locationId2 ?? client.locationId;
4040
- const headers = await client.buildHeaders();
4041
- const response = await fetch(`${SNIPPETS_BASE}/${loc}`, {
4042
- method: "POST",
4043
- headers,
4044
- body: JSON.stringify({
4045
- name,
4046
- template: { body, attachments: [] },
4047
- useForLiveChat: false,
4048
- urlAttachments: [],
4049
- type: type ?? "sms",
4050
- isFolder: false,
4051
- parentId: ""
4052
- })
4053
- });
4054
- if (!response.ok) {
4055
- const text2 = await response.text();
4056
- throw new Error(`Snippets API Error ${response.status}: POST /snippets/${loc}
4075
+ const loc = locationId2 ?? client.locationId;
4076
+ const headers = await client.buildHeaders();
4077
+ const response = await fetch(`${SNIPPETS_BASE}/${loc}`, {
4078
+ method: "POST",
4079
+ headers,
4080
+ body: JSON.stringify(buildSnippetPayload({ name, body, type }))
4081
+ });
4082
+ if (!response.ok) {
4083
+ const text2 = await response.text();
4084
+ throw new Error(`Snippets API Error ${response.status}: POST /snippets/${loc}
4057
4085
  ${text2}`);
4058
- }
4059
- const text = await response.text();
4060
- return jsonResponse(text ? JSON.parse(text) : { ok: true });
4061
- } catch (error) {
4062
- return errorResponse(error);
4063
4086
  }
4087
+ const text = await response.text();
4088
+ return text ? JSON.parse(text) : { ok: true };
4064
4089
  }
4065
4090
  );
4066
4091
  }
4092
+ function buildSnippetPayload(o) {
4093
+ return {
4094
+ name: o.name,
4095
+ template: { body: o.body, attachments: [] },
4096
+ useForLiveChat: false,
4097
+ urlAttachments: [],
4098
+ type: o.type ?? "sms",
4099
+ isFolder: false,
4100
+ parentId: ""
4101
+ };
4102
+ }
4067
4103
 
4068
4104
  // src/tools/trigger-links.ts
4069
4105
  var import_zod26 = require("zod");
@@ -4756,13 +4792,21 @@ function registerWorkflowBuilderTools(server2, client) {
4756
4792
  if (!client) {
4757
4793
  server2.tool(
4758
4794
  "workflow_builder_status",
4759
- "Check if the workflow builder (internal API) is configured. Requires GHL_FIREBASE_API_KEY, GHL_FIREBASE_REFRESH_TOKEN, GHL_API_KEY, GHL_LOCATION_ID, and GHL_USER_ID.",
4795
+ "Check if the workflow builder (internal API) is configured. Needs Firebase credentials, added via enable_workflow_builder.",
4760
4796
  {},
4761
4797
  async () => ({
4762
4798
  content: [
4763
4799
  {
4764
4800
  type: "text",
4765
- text: "Workflow builder is NOT configured. Missing required env vars: GHL_FIREBASE_API_KEY, GHL_FIREBASE_REFRESH_TOKEN, GHL_USER_ID. Set these in start-mcp.sh or .env to enable full workflow editing."
4801
+ text: [
4802
+ "Workflow builder is NOT configured (Firebase credentials missing).",
4803
+ "",
4804
+ "To enable the 49 Firebase-gated tools, run enable_workflow_builder with your three Firebase values (ghl_user_id, ghl_firebase_api_key, ghl_firebase_refresh_token) captured from a logged-in GHL browser tab. Step-by-step DevTools guide: https://elitedcs.com/ghl-mcp-firebase",
4805
+ "",
4806
+ "Do NOT add these as env vars in your Claude Desktop config \u2014 that path is unreliable and is the usual reason this still shows missing after a restart. enable_workflow_builder validates the values and saves them for you; then fully quit and reopen Claude.",
4807
+ "",
4808
+ "(Advanced / self-hosted wrapper only: GHL_FIREBASE_API_KEY, GHL_FIREBASE_REFRESH_TOKEN, GHL_USER_ID via start-mcp.sh or .env.)"
4809
+ ].join("\n")
4766
4810
  }
4767
4811
  ],
4768
4812
  isError: true
@@ -5827,7 +5871,7 @@ Note: Firebase credentials rejected (${fb.error}). Saved without Workflow Builde
5827
5871
  });
5828
5872
  const toolCount = workflowBuilderEnabled ? "212" : "163";
5829
5873
  const wfLine = workflowBuilderEnabled ? "Workflow Builder: enabled." : "Workflow Builder: not configured (optional).";
5830
- const wfTip = workflowBuilderEnabled ? "" : "\nTo enable Workflow Builder later (8 extra tools): run enable_workflow_builder with your three Firebase values. No need to re-enter license/API key/location ID.";
5874
+ const wfTip = workflowBuilderEnabled ? "" : "\nTo enable Workflow Builder later (49 extra Firebase-gated tools): run enable_workflow_builder with your three Firebase values. No need to re-enter license/API key/location ID.";
5831
5875
  return {
5832
5876
  content: [{
5833
5877
  type: "text",
@@ -6934,23 +6978,20 @@ ${text2}`);
6934
6978
  if (!text) return {};
6935
6979
  return JSON.parse(text);
6936
6980
  }
6937
- server2.tool(
6981
+ safeTool(
6982
+ server2,
6938
6983
  "get_review_link_list",
6939
- "List the review-link destinations configured for a location \u2014 the platforms (Google, Facebook, etc.) where review requests send contacts. Each entry has a label and the public review URL. Useful for: building review-request workflows (the workflow goal condition `review_request_clicked` references these review-link ids), and auditing which review platforms a sub-account has connected. NOTE: listing the actual reviews that come BACK (and responding to them) is not yet available \u2014 that endpoint needs a DevTools capture against a live account with connected review platforms.",
6984
+ "List the review-link destinations configured for a location \u2014 the platforms (Google, Facebook, etc.) where review requests send contacts. Each entry has a label and the public review URL. Useful for: building review-request workflows (the workflow goal condition `review_request_clicked` references these review-link ids), and auditing which review platforms a sub-account has connected.",
6940
6985
  {
6941
6986
  locationId: import_zod43.z.string().optional().describe("Location ID. Falls back to the active builder client's location.")
6942
6987
  },
6943
6988
  async ({ locationId: locationId2 }) => {
6944
- try {
6945
- const loc = locationId2 ?? client.locationId;
6946
- const result = await reputationRequest("GET", `/integrations/review-link-list?locationId=${loc}`);
6947
- return jsonResponse(result);
6948
- } catch (error) {
6949
- return errorResponse(error);
6950
- }
6989
+ const loc = locationId2 ?? client.locationId;
6990
+ return reputationRequest("GET", `/integrations/review-link-list?locationId=${loc}`);
6951
6991
  }
6952
6992
  );
6953
- server2.tool(
6993
+ safeTool(
6994
+ server2,
6954
6995
  "list_reviews",
6955
6996
  "List the reviews a location has received (Google, Facebook, etc.) with rating, author, text, reply status, and source. Supports paging and an optional rating filter. NOTE: location is resolved through nested filter params internally \u2014 a flat locationId is what caused the long-standing 'No Location Found' error, now fixed. Responding to a review is not yet available via API. Requires Firebase auth.",
6956
6997
  {
@@ -6961,33 +7002,31 @@ ${text2}`);
6961
7002
  includeDeleted: import_zod43.z.boolean().optional().describe("Include deleted reviews. Defaults to false.")
6962
7003
  },
6963
7004
  async ({ locationId: locationId2, pageNumber, pageSize, rating, includeDeleted }) => {
6964
- try {
6965
- const loc = locationId2 ?? client.locationId;
6966
- const q = [
6967
- `filterParams[locationId][0][value]=${encodeURIComponent(loc)}`,
6968
- `filterParams[locationId][0][condition]=eq`,
6969
- `filterParams[deleted][0][value]=${includeDeleted ? "true" : "false"}`,
6970
- `filterParams[deleted][0][condition]=eq`
6971
- ];
6972
- if (rating !== void 0) {
6973
- q.push(`filterParams[rating][0][value]=${rating}`);
6974
- q.push(`filterParams[rating][0][condition]=eq`);
6975
- }
6976
- q.push(`sortParams[dateAdded]=-1`);
6977
- q.push(`pageNumber=${pageNumber ?? 1}`);
6978
- q.push(`pageSize=${pageSize ?? 10}`);
6979
- const query = q.map((p) => {
6980
- const i = p.indexOf("=");
6981
- return `${encodeURIComponent(p.slice(0, i))}=${p.slice(i + 1)}`;
6982
- }).join("&");
6983
- const result = await reputationRequest("GET", `/reviews?${query}`);
6984
- return jsonResponse(result);
6985
- } catch (error) {
6986
- return errorResponse(error);
6987
- }
7005
+ const loc = locationId2 ?? client.locationId;
7006
+ return reputationRequest("GET", buildReviewsQuery(loc, { pageNumber, pageSize, rating, includeDeleted }));
6988
7007
  }
6989
7008
  );
6990
7009
  }
7010
+ function buildReviewsQuery(locationId2, opts = {}) {
7011
+ const q = [
7012
+ `filterParams[locationId][0][value]=${locationId2}`,
7013
+ `filterParams[locationId][0][condition]=eq`,
7014
+ `filterParams[deleted][0][value]=${opts.includeDeleted ? "true" : "false"}`,
7015
+ `filterParams[deleted][0][condition]=eq`
7016
+ ];
7017
+ if (opts.rating !== void 0) {
7018
+ q.push(`filterParams[rating][0][value]=${opts.rating}`);
7019
+ q.push(`filterParams[rating][0][condition]=eq`);
7020
+ }
7021
+ q.push(`sortParams[dateAdded]=-1`);
7022
+ q.push(`pageNumber=${opts.pageNumber ?? 1}`);
7023
+ q.push(`pageSize=${opts.pageSize ?? 10}`);
7024
+ const query = q.map((p) => {
7025
+ const i = p.indexOf("=");
7026
+ return `${encodeURIComponent(p.slice(0, i))}=${encodeURIComponent(p.slice(i + 1))}`;
7027
+ }).join("&");
7028
+ return `/reviews?${query}`;
7029
+ }
6991
7030
 
6992
7031
  // src/tools/email-campaigns.ts
6993
7032
  var import_zod44 = require("zod");
@@ -6995,7 +7034,8 @@ var SVC_BASE = "https://services.leadconnectorhq.com";
6995
7034
  function registerEmailCampaignTools(server2, builderClient) {
6996
7035
  const client = builderClient;
6997
7036
  if (!client) return;
6998
- server2.tool(
7037
+ safeTool(
7038
+ server2,
6999
7039
  "create_email_campaign",
7000
7040
  "Create an email campaign / broadcast DRAFT from an existing email template. Requires a templateId (create one first with create_email_template). The campaign is created as a draft \u2014 to actually SEND or schedule it, finish in the GHL UI: the send/schedule endpoint isn't available through the API yet. There's also no API delete for campaigns, so drafts created here are removed via the GHL UI. Despite those limits, this gets the campaign 90% built \u2014 template, subject, sender, name all set programmatically.",
7001
7041
  {
@@ -7010,36 +7050,32 @@ function registerEmailCampaignTools(server2, builderClient) {
7010
7050
  locationId: import_zod44.z.string().optional().describe("Location ID. Falls back to the active builder client's location.")
7011
7051
  },
7012
7052
  async ({ templateId, name, subject, fromName, fromEmail, isPlainText, enableResendToUnopened, hasUtmTracking, locationId: locationId2 }) => {
7013
- try {
7014
- const loc = locationId2 ?? client.locationId;
7015
- const body = { locationId: loc, templateId };
7016
- if (name !== void 0) body.name = name;
7017
- if (subject !== void 0) body.subject = subject;
7018
- if (fromName !== void 0) body.fromName = fromName;
7019
- if (fromEmail !== void 0) body.fromEmail = fromEmail;
7020
- if (isPlainText !== void 0) body.isPlainText = isPlainText;
7021
- if (enableResendToUnopened !== void 0) body.enableResendToUnopened = enableResendToUnopened;
7022
- if (hasUtmTracking !== void 0) body.hasUtmTracking = hasUtmTracking;
7023
- const headers = await client.buildHeaders();
7024
- const response = await fetch(`${SVC_BASE}/emails/schedule`, {
7025
- method: "POST",
7026
- headers,
7027
- body: JSON.stringify(body)
7028
- });
7029
- if (!response.ok) {
7030
- const text2 = await response.text();
7031
- throw new Error(`Email Campaign API Error ${response.status}: POST /emails/schedule
7053
+ const loc = locationId2 ?? client.locationId;
7054
+ const body = { locationId: loc, templateId };
7055
+ if (name !== void 0) body.name = name;
7056
+ if (subject !== void 0) body.subject = subject;
7057
+ if (fromName !== void 0) body.fromName = fromName;
7058
+ if (fromEmail !== void 0) body.fromEmail = fromEmail;
7059
+ if (isPlainText !== void 0) body.isPlainText = isPlainText;
7060
+ if (enableResendToUnopened !== void 0) body.enableResendToUnopened = enableResendToUnopened;
7061
+ if (hasUtmTracking !== void 0) body.hasUtmTracking = hasUtmTracking;
7062
+ const headers = await client.buildHeaders();
7063
+ const response = await fetch(`${SVC_BASE}/emails/schedule`, {
7064
+ method: "POST",
7065
+ headers,
7066
+ body: JSON.stringify(body)
7067
+ });
7068
+ if (!response.ok) {
7069
+ const text2 = await response.text();
7070
+ throw new Error(`Email Campaign API Error ${response.status}: POST /emails/schedule
7032
7071
  ${text2}`);
7033
- }
7034
- const text = await response.text();
7035
- const result = text ? JSON.parse(text) : {};
7036
- return jsonResponse({
7037
- ...result,
7038
- _note: "Campaign created as a DRAFT. To send or schedule it, open it in the GHL UI (Marketing \u2192 Emails) \u2014 the send endpoint isn't available via API. To delete a draft, use the GHL UI."
7039
- });
7040
- } catch (error) {
7041
- return errorResponse(error);
7042
7072
  }
7073
+ const text = await response.text();
7074
+ const result = text ? JSON.parse(text) : {};
7075
+ return {
7076
+ ...result,
7077
+ _note: "Campaign created as a DRAFT. To send or schedule it, open it in the GHL UI (Marketing \u2192 Emails) \u2014 the send endpoint isn't available via API. To delete a draft, use the GHL UI."
7078
+ };
7043
7079
  }
7044
7080
  );
7045
7081
  }
@@ -7066,23 +7102,20 @@ ${text2}`);
7066
7102
  if (!text) return {};
7067
7103
  return JSON.parse(text);
7068
7104
  }
7069
- server2.tool(
7105
+ safeTool(
7106
+ server2,
7070
7107
  "list_membership_offers",
7071
- "List a location's membership offers and products in one call. Returns { products: [...], offers: [...] }. Products are courses/communities; offers are the access grants (what a contact gets enrolled in). Use the returned ids with membership trigger conditions like offer_access_granted / product_completed. READ-ONLY \u2014 creating offers/products happens in the GHL Memberships UI.",
7108
+ "List a location's membership offers and products in one call. Returns { products: [...], offers: [...] }. Products are courses/communities; offers are the access grants (what a contact gets enrolled in). Use the returned ids with membership trigger conditions like offer_access_granted / product_completed.",
7072
7109
  {
7073
7110
  locationId: import_zod45.z.string().optional().describe("Location ID. Falls back to the active builder client's location.")
7074
7111
  },
7075
7112
  async ({ locationId: locationId2 }) => {
7076
- try {
7077
- const loc = locationId2 ?? client.locationId;
7078
- const result = await membershipRequest(`/smart-list/offers-products/${loc}`);
7079
- return jsonResponse(result);
7080
- } catch (error) {
7081
- return errorResponse(error);
7082
- }
7113
+ const loc = locationId2 ?? client.locationId;
7114
+ return membershipRequest(`/smart-list/offers-products/${loc}`);
7083
7115
  }
7084
7116
  );
7085
- server2.tool(
7117
+ safeTool(
7118
+ server2,
7086
7119
  "list_membership_categories",
7087
7120
  "List all membership/course categories in a location. Categories group lessons inside a course/product. Use the returned ids with the category_completed / category_started trigger conditions. READ-ONLY.",
7088
7121
  {
@@ -7090,16 +7123,12 @@ ${text2}`);
7090
7123
  locationId: import_zod45.z.string().optional().describe("Location ID. Falls back to the active builder client's location.")
7091
7124
  },
7092
7125
  async ({ limit, locationId: locationId2 }) => {
7093
- try {
7094
- const loc = locationId2 ?? client.locationId;
7095
- const result = await membershipRequest(`/smart-list/location/${loc}/workflow?type=category&limit=${limit ?? 1e5}`);
7096
- return jsonResponse(result);
7097
- } catch (error) {
7098
- return errorResponse(error);
7099
- }
7126
+ const loc = locationId2 ?? client.locationId;
7127
+ return membershipRequest(`/smart-list/location/${loc}/workflow?type=category&limit=${limit ?? 1e5}`);
7100
7128
  }
7101
7129
  );
7102
- server2.tool(
7130
+ safeTool(
7131
+ server2,
7103
7132
  "list_membership_lessons",
7104
7133
  "List all membership/course lessons in a location. Use the returned ids with the lesson_completed / lesson_started trigger conditions. READ-ONLY.",
7105
7134
  {
@@ -7107,16 +7136,12 @@ ${text2}`);
7107
7136
  locationId: import_zod45.z.string().optional().describe("Location ID. Falls back to the active builder client's location.")
7108
7137
  },
7109
7138
  async ({ limit, locationId: locationId2 }) => {
7110
- try {
7111
- const loc = locationId2 ?? client.locationId;
7112
- const result = await membershipRequest(`/smart-list/location/${loc}/workflow?type=lesson&limit=${limit ?? 1e5}`);
7113
- return jsonResponse(result);
7114
- } catch (error) {
7115
- return errorResponse(error);
7116
- }
7139
+ const loc = locationId2 ?? client.locationId;
7140
+ return membershipRequest(`/smart-list/location/${loc}/workflow?type=lesson&limit=${limit ?? 1e5}`);
7117
7141
  }
7118
7142
  );
7119
- server2.tool(
7143
+ safeTool(
7144
+ server2,
7120
7145
  "create_course",
7121
7146
  "Create a membership course (a 'product') in a location. Creates the course shell with a title and description; add categories (create_membership_category) and lessons (create_membership_lesson) into it, and an offer (create_membership_offer) to grant access. Returns the new product, including its id. Requires Firebase auth.",
7122
7147
  {
@@ -7125,19 +7150,12 @@ ${text2}`);
7125
7150
  locationId: import_zod45.z.string().optional().describe("Location ID. Falls back to the active builder client's location.")
7126
7151
  },
7127
7152
  async ({ title, description, locationId: locationId2 }) => {
7128
- try {
7129
- const loc = locationId2 ?? client.locationId;
7130
- const result = await membershipRequest(`/locations/${loc}/products`, "POST", {
7131
- title,
7132
- description: description ?? ""
7133
- });
7134
- return jsonResponse(result);
7135
- } catch (error) {
7136
- return errorResponse(error);
7137
- }
7153
+ const loc = locationId2 ?? client.locationId;
7154
+ return membershipRequest(`/locations/${loc}/products`, "POST", buildCoursePayload({ title, description }));
7138
7155
  }
7139
7156
  );
7140
- server2.tool(
7157
+ safeTool(
7158
+ server2,
7141
7159
  "create_membership_category",
7142
7160
  "Create a category (module/section) inside a membership course. Categories group lessons. Needs the productId of the course (from create_course or list_membership_offers). Returns the new category, including its id (use it as categoryId when creating lessons). Requires Firebase auth.",
7143
7161
  {
@@ -7150,29 +7168,12 @@ ${text2}`);
7150
7168
  locationId: import_zod45.z.string().optional().describe("Location ID. Falls back to the active builder client's location.")
7151
7169
  },
7152
7170
  async ({ title, productId, description, visibility, sequenceNo, dripDays, locationId: locationId2 }) => {
7153
- try {
7154
- const loc = locationId2 ?? client.locationId;
7155
- const result = await membershipRequest(`/locations/${loc}/categories`, "POST", {
7156
- title,
7157
- productId,
7158
- visibility: visibility ?? "published",
7159
- sequenceNo: sequenceNo ?? 0,
7160
- dripDays: dripDays ?? 0,
7161
- description: description ?? "",
7162
- parentCategory: null,
7163
- posterImage: "",
7164
- lockedBy: null,
7165
- lockedByCategory: null,
7166
- commentPermission: null,
7167
- metadata: null
7168
- });
7169
- return jsonResponse(result);
7170
- } catch (error) {
7171
- return errorResponse(error);
7172
- }
7171
+ const loc = locationId2 ?? client.locationId;
7172
+ return membershipRequest(`/locations/${loc}/categories`, "POST", buildCategoryPayload({ title, productId, description, visibility, sequenceNo, dripDays }));
7173
7173
  }
7174
7174
  );
7175
- server2.tool(
7175
+ safeTool(
7176
+ server2,
7176
7177
  "create_membership_lesson",
7177
7178
  "Create a lesson (a 'post') inside a membership course category. Needs both the categoryId (from create_membership_category) and the productId of the course. Description is the lesson body as HTML. Returns the new lesson, including its id. Requires Firebase auth.",
7178
7179
  {
@@ -7186,32 +7187,12 @@ ${text2}`);
7186
7187
  locationId: import_zod45.z.string().optional().describe("Location ID. Falls back to the active builder client's location.")
7187
7188
  },
7188
7189
  async ({ title, categoryId, productId, description, contentType, visibility, sequenceNo, locationId: locationId2 }) => {
7189
- try {
7190
- const loc = locationId2 ?? client.locationId;
7191
- const result = await membershipRequest(`/locations/${loc}/posts`, "POST", {
7192
- title,
7193
- description: description ?? "",
7194
- categoryId,
7195
- productId,
7196
- visibility: visibility ?? "published",
7197
- sequenceNo: sequenceNo ?? 0,
7198
- posterImage: null,
7199
- commentStatus: "visible",
7200
- contentType: contentType ?? "video",
7201
- commentPermission: "enabled",
7202
- lockedByPost: null,
7203
- lockedByCategory: null,
7204
- certificateTemplateId: null,
7205
- metaData: null,
7206
- contentId: null
7207
- });
7208
- return jsonResponse(result);
7209
- } catch (error) {
7210
- return errorResponse(error);
7211
- }
7190
+ const loc = locationId2 ?? client.locationId;
7191
+ return membershipRequest(`/locations/${loc}/posts`, "POST", buildLessonPayload({ title, categoryId, productId, description, contentType, visibility, sequenceNo }));
7212
7192
  }
7213
7193
  );
7214
- server2.tool(
7194
+ safeTool(
7195
+ server2,
7215
7196
  "create_membership_offer",
7216
7197
  "Create a membership offer \u2014 the access grant that enrolls contacts into one or more courses/products. Link it to course product ids. Defaults to a free offer; for paid, set type to 'recurring' or 'one_time' with an amount. Returns the new offer, including its id (referenced by the offer_access_granted trigger). Requires Firebase auth.",
7217
7198
  {
@@ -7223,24 +7204,60 @@ ${text2}`);
7223
7204
  locationId: import_zod45.z.string().optional().describe("Location ID. Falls back to the active builder client's location.")
7224
7205
  },
7225
7206
  async ({ title, productIds, type, amount, currency, locationId: locationId2 }) => {
7226
- try {
7227
- const loc = locationId2 ?? client.locationId;
7228
- const result = await membershipRequest(`/locations/${loc}/offers`, "POST", {
7229
- title,
7230
- type: type ?? "free",
7231
- isLivePaymentMode: true,
7232
- locationId: loc,
7233
- productIds,
7234
- amount: amount ?? 0,
7235
- currency: currency ?? "USD"
7236
- });
7237
- return jsonResponse(result);
7238
- } catch (error) {
7239
- return errorResponse(error);
7240
- }
7207
+ const loc = locationId2 ?? client.locationId;
7208
+ return membershipRequest(`/locations/${loc}/offers`, "POST", buildOfferPayload({ title, productIds, type, amount, currency, locationId: loc }));
7241
7209
  }
7242
7210
  );
7243
7211
  }
7212
+ function buildCoursePayload(o) {
7213
+ return { title: o.title, description: o.description ?? "" };
7214
+ }
7215
+ function buildCategoryPayload(o) {
7216
+ return {
7217
+ title: o.title,
7218
+ productId: o.productId,
7219
+ visibility: o.visibility ?? "published",
7220
+ sequenceNo: o.sequenceNo ?? 0,
7221
+ dripDays: o.dripDays ?? 0,
7222
+ description: o.description ?? "",
7223
+ parentCategory: null,
7224
+ posterImage: "",
7225
+ lockedBy: null,
7226
+ lockedByCategory: null,
7227
+ commentPermission: null,
7228
+ metadata: null
7229
+ };
7230
+ }
7231
+ function buildLessonPayload(o) {
7232
+ return {
7233
+ title: o.title,
7234
+ description: o.description ?? "",
7235
+ categoryId: o.categoryId,
7236
+ productId: o.productId,
7237
+ visibility: o.visibility ?? "published",
7238
+ sequenceNo: o.sequenceNo ?? 0,
7239
+ posterImage: null,
7240
+ commentStatus: "visible",
7241
+ contentType: o.contentType ?? "video",
7242
+ commentPermission: "enabled",
7243
+ lockedByPost: null,
7244
+ lockedByCategory: null,
7245
+ certificateTemplateId: null,
7246
+ metaData: null,
7247
+ contentId: null
7248
+ };
7249
+ }
7250
+ function buildOfferPayload(o) {
7251
+ return {
7252
+ title: o.title,
7253
+ type: o.type ?? "free",
7254
+ isLivePaymentMode: true,
7255
+ locationId: o.locationId,
7256
+ productIds: o.productIds,
7257
+ amount: o.amount ?? 0,
7258
+ currency: o.currency ?? "USD"
7259
+ };
7260
+ }
7244
7261
 
7245
7262
  // src/tools/template-deployer.ts
7246
7263
  var import_zod46 = require("zod");
@@ -8033,7 +8050,7 @@ function registerDiagnosticTools(server2, installedVersion, client, builderClien
8033
8050
  })();
8034
8051
  const firebasePromise = (async () => {
8035
8052
  if (!builderClient) {
8036
- return { name: "Firebase auth (workflow builder)", status: "skip", detail: "Not configured. The 8 workflow builder tools need Firebase credentials. The other 168 tools work fine without. To add it: run enable_workflow_builder with the three Firebase values from your GHL browser session (see elitedcs.com/ghl-mcp-firebase for DevTools steps)." };
8053
+ return { name: "Firebase auth (workflow builder)", status: "skip", detail: "Not configured. The 49 Firebase-gated tools need Firebase credentials. The other 163 tools work fine without. To add it: run enable_workflow_builder with the three Firebase values from your GHL browser session (see elitedcs.com/ghl-mcp-firebase for DevTools steps). Do NOT put Firebase values as env vars in your Claude Desktop config \u2014 that path is unreliable and is the usual reason this still shows skip after a restart. enable_workflow_builder saves and verifies them for you." };
8037
8054
  }
8038
8055
  const result = await builderClient.checkAuth();
8039
8056
  const activeCompany = builderClient.getCurrentCompanyId();
@@ -8131,7 +8148,7 @@ function registerAllTools(server2, client, registry2, mcpVersion) {
8131
8148
  for (const [register] of publicApiTools) {
8132
8149
  register(server2, client);
8133
8150
  }
8134
- const builderClient = WorkflowBuilderClient.fromEnv(registry2);
8151
+ const builderClient = WorkflowBuilderClient.fromEnv(registry2) ?? WorkflowBuilderClient.fromFirstCompany(registry2);
8135
8152
  registerWorkflowBuilderTools(server2, builderClient);
8136
8153
  registerFunnelBuilderTools(server2, builderClient);
8137
8154
  registerFormBuilderTools(server2, builderClient);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@elitedcs/ghl-mcp",
3
- "version": "3.16.1",
3
+ "version": "3.17.1",
4
4
  "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.",
5
5
  "main": "dist/index.js",
6
6
  "bin": {
@@ -37,10 +37,11 @@
37
37
  "homepage": "https://elitedcs.com/ghl-mcp-server",
38
38
  "repository": {
39
39
  "type": "git",
40
- "url": "git+https://github.com/drjerryrelth/elite-dcs-ghl-mcp.git"
40
+ "url": "git+https://github.com/drjerryrelth/ghl-command-feedback.git"
41
41
  },
42
42
  "bugs": {
43
- "url": "https://github.com/drjerryrelth/elite-dcs-ghl-mcp/issues"
43
+ "url": "https://github.com/drjerryrelth/ghl-command-feedback/issues",
44
+ "email": "support@cliniclaunchlab.com"
44
45
  },
45
46
  "publishConfig": {
46
47
  "access": "public"