@elitedcs/ghl-mcp 3.16.0 → 3.17.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 CHANGED
@@ -1,5 +1,26 @@
1
1
  # Changelog
2
2
 
3
+ ## 3.17.0 — Multi-tenant bootstrap, internal hardening, test coverage
4
+
5
+ **No new tools — still 212 across 43 modules. Internal hardening + a multi-tenant fix.**
6
+
7
+ ### Multi-tenant: bootstrap from a client company's Firebase
8
+
9
+ 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).
10
+
11
+ ### Internal hardening
12
+
13
+ - **`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.
14
+ - **Extracted request-body builders** (`buildCoursePayload`, `buildCategoryPayload`, `buildLessonPayload`, `buildOfferPayload`, `buildSnippetPayload`, `buildReviewsQuery`) as pure, exported functions.
15
+ - **+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.
16
+ - **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).
17
+
18
+ ## 3.16.1 — Docs: correct enable_workflow_builder tool counts
19
+
20
+ **No tool changes. Still 212 tools across 43 modules (163 without Firebase, 49 Firebase-gated).**
21
+
22
+ The `enable_workflow_builder` tool description and its success message had drifted out of date, still quoting the pre-v3.15 numbers ("30 additional tools across 6 modules", "163 to 203", "203 total"). Corrected to the current 49 Firebase-gated tools (163 → 212) and expanded the module list to match what's actually gated (smart lists, reputation, email campaigns, email templates, and memberships, plus the pre-deploy validator, were all missing).
23
+
3
24
  ## 3.16.0 — Membership/course creates, SMS templates, reviews list (Firebase-gated)
4
25
 
5
26
  **212 tools across 43 modules (+6). Build courses and SMS templates from Claude; read the reviews that come back.**
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.0",
34
+ version: "3.17.0",
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: {
@@ -514,6 +514,19 @@ var TokenRegistry = class {
514
514
  name: token.name
515
515
  }));
516
516
  }
517
+ /**
518
+ * Find the first registered location belonging to a company. Used to
519
+ * bootstrap the workflow-builder client from a company Firebase entry when
520
+ * the install has no home Firebase (multi-tenant-only installs).
521
+ */
522
+ firstLocationForCompany(companyId) {
523
+ for (const [locationId2, token] of Object.entries(this.data.tokens)) {
524
+ if (token.companyId === companyId) {
525
+ return { locationId: locationId2, apiKey: token.apiKey };
526
+ }
527
+ }
528
+ return void 0;
529
+ }
517
530
  /**
518
531
  * Update the home Firebase refresh token (called on rotation)
519
532
  */
@@ -1226,6 +1239,37 @@ var WorkflowBuilderClient = class _WorkflowBuilderClient {
1226
1239
  registry: registry2
1227
1240
  });
1228
1241
  }
1242
+ /**
1243
+ * Bootstrap from a registered CLIENT company's Firebase when there is no home
1244
+ * Firebase (multi-tenant-only install — e.g. an agency operating purely in
1245
+ * clients' accounts). Without this, fromEnv() returns null and every
1246
+ * Firebase-gated tool fails to register even after register_company_firebase.
1247
+ *
1248
+ * Picks the first company that has both Firebase creds and a registered
1249
+ * location, and makes that company the active (and "home") slot. switch_location
1250
+ * can still route to other companies afterward. Returns null if no such pair
1251
+ * exists. The chosen company is treated as home, so token rotation persists
1252
+ * back to its firebaseByCompany slot (see persistRotatedToken).
1253
+ */
1254
+ static fromFirstCompany(registry2) {
1255
+ if (!registry2) return null;
1256
+ for (const { companyId } of registry2.listCompanyFirebases()) {
1257
+ const fb = registry2.getCompanyFirebase(companyId);
1258
+ const loc = registry2.firstLocationForCompany(companyId);
1259
+ if (fb && loc) {
1260
+ return new _WorkflowBuilderClient({
1261
+ firebaseApiKey: fb.apiKey,
1262
+ refreshToken: fb.refreshToken,
1263
+ apiKey: loc.apiKey,
1264
+ locationId: loc.locationId,
1265
+ userId: fb.userId,
1266
+ companyId,
1267
+ registry: registry2
1268
+ });
1269
+ }
1270
+ }
1271
+ return null;
1272
+ }
1229
1273
  /**
1230
1274
  * Get a fresh Firebase ID token using the refresh token.
1231
1275
  * Uses a promise lock to prevent concurrent refresh races.
@@ -1278,9 +1322,12 @@ var WorkflowBuilderClient = class _WorkflowBuilderClient {
1278
1322
  */
1279
1323
  persistRotatedToken(newToken) {
1280
1324
  const company = this.currentCompanyId;
1281
- const isClientCompany = company !== void 0 && company !== this.homeCompanyId && this.registry?.getCompanyFirebase(company) !== void 0;
1282
- if (isClientCompany) {
1325
+ const hasCompanySlot = company !== void 0 && this.registry?.getCompanyFirebase(company) !== void 0;
1326
+ if (hasCompanySlot) {
1283
1327
  this.registry?.updateCompanyFirebaseRefreshToken(company, newToken);
1328
+ if (company === this.homeCompanyId) {
1329
+ this.homeRefreshToken = newToken;
1330
+ }
1284
1331
  return;
1285
1332
  }
1286
1333
  if (company === void 0 || company === this.homeCompanyId) {
@@ -3972,7 +4019,8 @@ ${text2}`);
3972
4019
  const text = await response.text();
3973
4020
  return text ? JSON.parse(text) : { ok: true };
3974
4021
  }
3975
- server2.tool(
4022
+ safeTool(
4023
+ server2,
3976
4024
  "delete_email_template",
3977
4025
  "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
4026
  {
@@ -3980,16 +4028,12 @@ ${text2}`);
3980
4028
  locationId: import_zod25.z.string().optional().describe("Location ID. Falls back to the active builder client's location.")
3981
4029
  },
3982
4030
  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
- }
4031
+ const loc = locationId2 ?? client.locationId;
4032
+ return builderRequest("DELETE", `/data/${templateId}?locationId=${loc}`);
3990
4033
  }
3991
4034
  );
3992
- server2.tool(
4035
+ safeTool(
4036
+ server2,
3993
4037
  "rename_email_template",
3994
4038
  "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
4039
  {
@@ -3998,16 +4042,12 @@ ${text2}`);
3998
4042
  locationId: import_zod25.z.string().optional().describe("Location ID. Falls back to the active builder client's location.")
3999
4043
  },
4000
4044
  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
- }
4045
+ const loc = locationId2 ?? client.locationId;
4046
+ return builderRequest("PATCH", `/${templateId}`, { locationId: loc, name });
4008
4047
  }
4009
4048
  );
4010
- server2.tool(
4049
+ safeTool(
4050
+ server2,
4011
4051
  "archive_email_template",
4012
4052
  "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
4053
  {
@@ -4016,16 +4056,12 @@ ${text2}`);
4016
4056
  locationId: import_zod25.z.string().optional().describe("Location ID. Falls back to the active builder client's location.")
4017
4057
  },
4018
4058
  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
- }
4059
+ const loc = locationId2 ?? client.locationId;
4060
+ return builderRequest("PATCH", `/${templateId}`, { locationId: loc, archived: archived ?? true });
4026
4061
  }
4027
4062
  );
4028
- server2.tool(
4063
+ safeTool(
4064
+ server2,
4029
4065
  "create_sms_template",
4030
4066
  "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
4067
  {
@@ -4035,35 +4071,34 @@ ${text2}`);
4035
4071
  locationId: import_zod25.z.string().optional().describe("Location ID. Falls back to the active builder client's location.")
4036
4072
  },
4037
4073
  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}
4074
+ const loc = locationId2 ?? client.locationId;
4075
+ const headers = await client.buildHeaders();
4076
+ const response = await fetch(`${SNIPPETS_BASE}/${loc}`, {
4077
+ method: "POST",
4078
+ headers,
4079
+ body: JSON.stringify(buildSnippetPayload({ name, body, type }))
4080
+ });
4081
+ if (!response.ok) {
4082
+ const text2 = await response.text();
4083
+ throw new Error(`Snippets API Error ${response.status}: POST /snippets/${loc}
4057
4084
  ${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
4085
  }
4086
+ const text = await response.text();
4087
+ return text ? JSON.parse(text) : { ok: true };
4064
4088
  }
4065
4089
  );
4066
4090
  }
4091
+ function buildSnippetPayload(o) {
4092
+ return {
4093
+ name: o.name,
4094
+ template: { body: o.body, attachments: [] },
4095
+ useForLiveChat: false,
4096
+ urlAttachments: [],
4097
+ type: o.type ?? "sms",
4098
+ isFolder: false,
4099
+ parentId: ""
4100
+ };
4101
+ }
4067
4102
 
4068
4103
  // src/tools/trigger-links.ts
4069
4104
  var import_zod26 = require("zod");
@@ -5827,7 +5862,7 @@ Note: Firebase credentials rejected (${fb.error}). Saved without Workflow Builde
5827
5862
  });
5828
5863
  const toolCount = workflowBuilderEnabled ? "212" : "163";
5829
5864
  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.";
5865
+ 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
5866
  return {
5832
5867
  content: [{
5833
5868
  type: "text",
@@ -5853,7 +5888,7 @@ Note: Firebase credentials rejected (${fb.error}). Saved without Workflow Builde
5853
5888
  function registerEnableWorkflowBuilderTool(server2) {
5854
5889
  server2.tool(
5855
5890
  "enable_workflow_builder",
5856
- "Add Firebase credentials to an existing GHL Command install to unlock 30 additional tools across 6 modules: workflow builder (create/edit/clone/delete/publish/validate workflows, build_if_else_branch, build_goal_event, get_trigger_registry), funnel + page builder (10 tools), form builder (5 tools), pipeline builder (5 tools), and workflow cloning. Requires you've already run setup_ghl_mcp. Capture the three Firebase values from your GHL browser session \u2014 see elitedcs.com/ghl-mcp-firebase for step-by-step DevTools instructions. Tool count goes from 163 to 203 after the next Claude restart.",
5891
+ "Add Firebase credentials to an existing GHL Command install to unlock 49 additional tools across the internal-API modules: workflow builder (create/edit/clone/delete/publish/validate workflows, build_if_else_branch, build_goal_event, get_trigger_registry), funnel + page builder, form builder, pipeline builder, workflow cloner, smart lists, reputation, email campaigns, email templates, and memberships, plus the pre-deploy validator. Requires you've already run setup_ghl_mcp. Capture the three Firebase values from your GHL browser session \u2014 see elitedcs.com/ghl-mcp-firebase for step-by-step DevTools instructions. Tool count goes from 163 to 212 after the next Claude restart.",
5857
5892
  {
5858
5893
  ghl_user_id: import_zod37.z.string().min(10).describe("Firebase User ID (uid). DevTools \u2192 Application \u2192 IndexedDB \u2192 firebaseLocalStorageDb \u2192 firebaseLocalStorage \u2192 the value.uid field of the firebase:authUser row."),
5859
5894
  ghl_firebase_api_key: import_zod37.z.string().min(10).describe("Firebase API Key starting with 'AIza'. The string between 'firebase:authUser:' and ':[DEFAULT]' in the row's Key column."),
@@ -5900,7 +5935,7 @@ DevTools steps: https://elitedcs.com/ghl-mcp-firebase`
5900
5935
  "",
5901
5936
  "Firebase credentials verified and saved.",
5902
5937
  "",
5903
- "**Restart Claude (quit fully and reopen) to load the workflow builder + funnel builder + pipeline builder + form builder + workflow cloner tools (203 total).**",
5938
+ "**Restart Claude (quit fully and reopen) to load the workflow builder + funnel builder + pipeline builder + form builder + workflow cloner tools (212 total).**",
5904
5939
  "",
5905
5940
  'After restart, try: "List my workflows in full detail" or "Validate workflow <id>".',
5906
5941
  "",
@@ -6934,23 +6969,20 @@ ${text2}`);
6934
6969
  if (!text) return {};
6935
6970
  return JSON.parse(text);
6936
6971
  }
6937
- server2.tool(
6972
+ safeTool(
6973
+ server2,
6938
6974
  "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.",
6975
+ "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
6976
  {
6941
6977
  locationId: import_zod43.z.string().optional().describe("Location ID. Falls back to the active builder client's location.")
6942
6978
  },
6943
6979
  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
- }
6980
+ const loc = locationId2 ?? client.locationId;
6981
+ return reputationRequest("GET", `/integrations/review-link-list?locationId=${loc}`);
6951
6982
  }
6952
6983
  );
6953
- server2.tool(
6984
+ safeTool(
6985
+ server2,
6954
6986
  "list_reviews",
6955
6987
  "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
6988
  {
@@ -6961,33 +6993,31 @@ ${text2}`);
6961
6993
  includeDeleted: import_zod43.z.boolean().optional().describe("Include deleted reviews. Defaults to false.")
6962
6994
  },
6963
6995
  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
- }
6996
+ const loc = locationId2 ?? client.locationId;
6997
+ return reputationRequest("GET", buildReviewsQuery(loc, { pageNumber, pageSize, rating, includeDeleted }));
6988
6998
  }
6989
6999
  );
6990
7000
  }
7001
+ function buildReviewsQuery(locationId2, opts = {}) {
7002
+ const q = [
7003
+ `filterParams[locationId][0][value]=${locationId2}`,
7004
+ `filterParams[locationId][0][condition]=eq`,
7005
+ `filterParams[deleted][0][value]=${opts.includeDeleted ? "true" : "false"}`,
7006
+ `filterParams[deleted][0][condition]=eq`
7007
+ ];
7008
+ if (opts.rating !== void 0) {
7009
+ q.push(`filterParams[rating][0][value]=${opts.rating}`);
7010
+ q.push(`filterParams[rating][0][condition]=eq`);
7011
+ }
7012
+ q.push(`sortParams[dateAdded]=-1`);
7013
+ q.push(`pageNumber=${opts.pageNumber ?? 1}`);
7014
+ q.push(`pageSize=${opts.pageSize ?? 10}`);
7015
+ const query = q.map((p) => {
7016
+ const i = p.indexOf("=");
7017
+ return `${encodeURIComponent(p.slice(0, i))}=${encodeURIComponent(p.slice(i + 1))}`;
7018
+ }).join("&");
7019
+ return `/reviews?${query}`;
7020
+ }
6991
7021
 
6992
7022
  // src/tools/email-campaigns.ts
6993
7023
  var import_zod44 = require("zod");
@@ -6995,7 +7025,8 @@ var SVC_BASE = "https://services.leadconnectorhq.com";
6995
7025
  function registerEmailCampaignTools(server2, builderClient) {
6996
7026
  const client = builderClient;
6997
7027
  if (!client) return;
6998
- server2.tool(
7028
+ safeTool(
7029
+ server2,
6999
7030
  "create_email_campaign",
7000
7031
  "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
7032
  {
@@ -7010,36 +7041,32 @@ function registerEmailCampaignTools(server2, builderClient) {
7010
7041
  locationId: import_zod44.z.string().optional().describe("Location ID. Falls back to the active builder client's location.")
7011
7042
  },
7012
7043
  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
7044
+ const loc = locationId2 ?? client.locationId;
7045
+ const body = { locationId: loc, templateId };
7046
+ if (name !== void 0) body.name = name;
7047
+ if (subject !== void 0) body.subject = subject;
7048
+ if (fromName !== void 0) body.fromName = fromName;
7049
+ if (fromEmail !== void 0) body.fromEmail = fromEmail;
7050
+ if (isPlainText !== void 0) body.isPlainText = isPlainText;
7051
+ if (enableResendToUnopened !== void 0) body.enableResendToUnopened = enableResendToUnopened;
7052
+ if (hasUtmTracking !== void 0) body.hasUtmTracking = hasUtmTracking;
7053
+ const headers = await client.buildHeaders();
7054
+ const response = await fetch(`${SVC_BASE}/emails/schedule`, {
7055
+ method: "POST",
7056
+ headers,
7057
+ body: JSON.stringify(body)
7058
+ });
7059
+ if (!response.ok) {
7060
+ const text2 = await response.text();
7061
+ throw new Error(`Email Campaign API Error ${response.status}: POST /emails/schedule
7032
7062
  ${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
7063
  }
7064
+ const text = await response.text();
7065
+ const result = text ? JSON.parse(text) : {};
7066
+ return {
7067
+ ...result,
7068
+ _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."
7069
+ };
7043
7070
  }
7044
7071
  );
7045
7072
  }
@@ -7066,23 +7093,20 @@ ${text2}`);
7066
7093
  if (!text) return {};
7067
7094
  return JSON.parse(text);
7068
7095
  }
7069
- server2.tool(
7096
+ safeTool(
7097
+ server2,
7070
7098
  "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.",
7099
+ "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
7100
  {
7073
7101
  locationId: import_zod45.z.string().optional().describe("Location ID. Falls back to the active builder client's location.")
7074
7102
  },
7075
7103
  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
- }
7104
+ const loc = locationId2 ?? client.locationId;
7105
+ return membershipRequest(`/smart-list/offers-products/${loc}`);
7083
7106
  }
7084
7107
  );
7085
- server2.tool(
7108
+ safeTool(
7109
+ server2,
7086
7110
  "list_membership_categories",
7087
7111
  "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
7112
  {
@@ -7090,16 +7114,12 @@ ${text2}`);
7090
7114
  locationId: import_zod45.z.string().optional().describe("Location ID. Falls back to the active builder client's location.")
7091
7115
  },
7092
7116
  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
- }
7117
+ const loc = locationId2 ?? client.locationId;
7118
+ return membershipRequest(`/smart-list/location/${loc}/workflow?type=category&limit=${limit ?? 1e5}`);
7100
7119
  }
7101
7120
  );
7102
- server2.tool(
7121
+ safeTool(
7122
+ server2,
7103
7123
  "list_membership_lessons",
7104
7124
  "List all membership/course lessons in a location. Use the returned ids with the lesson_completed / lesson_started trigger conditions. READ-ONLY.",
7105
7125
  {
@@ -7107,16 +7127,12 @@ ${text2}`);
7107
7127
  locationId: import_zod45.z.string().optional().describe("Location ID. Falls back to the active builder client's location.")
7108
7128
  },
7109
7129
  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
- }
7130
+ const loc = locationId2 ?? client.locationId;
7131
+ return membershipRequest(`/smart-list/location/${loc}/workflow?type=lesson&limit=${limit ?? 1e5}`);
7117
7132
  }
7118
7133
  );
7119
- server2.tool(
7134
+ safeTool(
7135
+ server2,
7120
7136
  "create_course",
7121
7137
  "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
7138
  {
@@ -7125,19 +7141,12 @@ ${text2}`);
7125
7141
  locationId: import_zod45.z.string().optional().describe("Location ID. Falls back to the active builder client's location.")
7126
7142
  },
7127
7143
  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
- }
7144
+ const loc = locationId2 ?? client.locationId;
7145
+ return membershipRequest(`/locations/${loc}/products`, "POST", buildCoursePayload({ title, description }));
7138
7146
  }
7139
7147
  );
7140
- server2.tool(
7148
+ safeTool(
7149
+ server2,
7141
7150
  "create_membership_category",
7142
7151
  "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
7152
  {
@@ -7150,29 +7159,12 @@ ${text2}`);
7150
7159
  locationId: import_zod45.z.string().optional().describe("Location ID. Falls back to the active builder client's location.")
7151
7160
  },
7152
7161
  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
- }
7162
+ const loc = locationId2 ?? client.locationId;
7163
+ return membershipRequest(`/locations/${loc}/categories`, "POST", buildCategoryPayload({ title, productId, description, visibility, sequenceNo, dripDays }));
7173
7164
  }
7174
7165
  );
7175
- server2.tool(
7166
+ safeTool(
7167
+ server2,
7176
7168
  "create_membership_lesson",
7177
7169
  "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
7170
  {
@@ -7186,32 +7178,12 @@ ${text2}`);
7186
7178
  locationId: import_zod45.z.string().optional().describe("Location ID. Falls back to the active builder client's location.")
7187
7179
  },
7188
7180
  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
- }
7181
+ const loc = locationId2 ?? client.locationId;
7182
+ return membershipRequest(`/locations/${loc}/posts`, "POST", buildLessonPayload({ title, categoryId, productId, description, contentType, visibility, sequenceNo }));
7212
7183
  }
7213
7184
  );
7214
- server2.tool(
7185
+ safeTool(
7186
+ server2,
7215
7187
  "create_membership_offer",
7216
7188
  "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
7189
  {
@@ -7223,24 +7195,60 @@ ${text2}`);
7223
7195
  locationId: import_zod45.z.string().optional().describe("Location ID. Falls back to the active builder client's location.")
7224
7196
  },
7225
7197
  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
- }
7198
+ const loc = locationId2 ?? client.locationId;
7199
+ return membershipRequest(`/locations/${loc}/offers`, "POST", buildOfferPayload({ title, productIds, type, amount, currency, locationId: loc }));
7241
7200
  }
7242
7201
  );
7243
7202
  }
7203
+ function buildCoursePayload(o) {
7204
+ return { title: o.title, description: o.description ?? "" };
7205
+ }
7206
+ function buildCategoryPayload(o) {
7207
+ return {
7208
+ title: o.title,
7209
+ productId: o.productId,
7210
+ visibility: o.visibility ?? "published",
7211
+ sequenceNo: o.sequenceNo ?? 0,
7212
+ dripDays: o.dripDays ?? 0,
7213
+ description: o.description ?? "",
7214
+ parentCategory: null,
7215
+ posterImage: "",
7216
+ lockedBy: null,
7217
+ lockedByCategory: null,
7218
+ commentPermission: null,
7219
+ metadata: null
7220
+ };
7221
+ }
7222
+ function buildLessonPayload(o) {
7223
+ return {
7224
+ title: o.title,
7225
+ description: o.description ?? "",
7226
+ categoryId: o.categoryId,
7227
+ productId: o.productId,
7228
+ visibility: o.visibility ?? "published",
7229
+ sequenceNo: o.sequenceNo ?? 0,
7230
+ posterImage: null,
7231
+ commentStatus: "visible",
7232
+ contentType: o.contentType ?? "video",
7233
+ commentPermission: "enabled",
7234
+ lockedByPost: null,
7235
+ lockedByCategory: null,
7236
+ certificateTemplateId: null,
7237
+ metaData: null,
7238
+ contentId: null
7239
+ };
7240
+ }
7241
+ function buildOfferPayload(o) {
7242
+ return {
7243
+ title: o.title,
7244
+ type: o.type ?? "free",
7245
+ isLivePaymentMode: true,
7246
+ locationId: o.locationId,
7247
+ productIds: o.productIds,
7248
+ amount: o.amount ?? 0,
7249
+ currency: o.currency ?? "USD"
7250
+ };
7251
+ }
7244
7252
 
7245
7253
  // src/tools/template-deployer.ts
7246
7254
  var import_zod46 = require("zod");
@@ -8033,7 +8041,7 @@ function registerDiagnosticTools(server2, installedVersion, client, builderClien
8033
8041
  })();
8034
8042
  const firebasePromise = (async () => {
8035
8043
  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)." };
8044
+ 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)." };
8037
8045
  }
8038
8046
  const result = await builderClient.checkAuth();
8039
8047
  const activeCompany = builderClient.getCurrentCompanyId();
@@ -8131,7 +8139,7 @@ function registerAllTools(server2, client, registry2, mcpVersion) {
8131
8139
  for (const [register] of publicApiTools) {
8132
8140
  register(server2, client);
8133
8141
  }
8134
- const builderClient = WorkflowBuilderClient.fromEnv(registry2);
8142
+ const builderClient = WorkflowBuilderClient.fromEnv(registry2) ?? WorkflowBuilderClient.fromFirstCompany(registry2);
8135
8143
  registerWorkflowBuilderTools(server2, builderClient);
8136
8144
  registerFunnelBuilderTools(server2, builderClient);
8137
8145
  registerFormBuilderTools(server2, builderClient);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@elitedcs/ghl-mcp",
3
- "version": "3.16.0",
3
+ "version": "3.17.0",
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": {