@elitedcs/ghl-mcp 3.34.9 → 3.35.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/CHANGELOG.md +47 -0
  2. package/dist/index.js +128 -16
  3. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -1,5 +1,52 @@
1
1
  # Changelog
2
2
 
3
+ ## 3.35.0 — `create_social_post` is now draft-first (closes the silent "everything published at once" trap)
4
+
5
+ **Behavior change.** `create_social_post` no longer defers go-live to GoHighLevel's
6
+ server-side default. Before, when `status` was omitted the field was dropped from the
7
+ request and GHL decided whether the post went live — an undocumented default that, in
8
+ a loop, is exactly the "all posts fired at once, no error, no warning" failure operators
9
+ hit. The most expensive failures are the silent ones, so the tool now takes a defensive
10
+ stance by default.
11
+
12
+ - **Draft-first default.** Omit `status` and the post is created as a `draft` (editable,
13
+ never live). Going live is now an explicit choice (`status:"published"`).
14
+ - **Scheduling is coupled to status.** Pass `scheduledAt` with no `status` and it
15
+ auto-resolves to `"scheduled"`. Pass `scheduledAt` alongside any *other* status, or
16
+ `status:"scheduled"` with no `scheduledAt`, and the call is rejected with an actionable
17
+ error instead of silently publishing now or falling back to the server default. This
18
+ kills the "I thought it was scheduled" class of failure (CHANGELOG 3.18.0 always
19
+ required both fields; nothing enforced the pairing until now).
20
+ - Logic extracted to the pure, tested `resolvePostStatus(status, scheduledAt)` helper;
21
+ tool and field descriptions updated to document the contract. 5 regression tests added.
22
+ - **Migration:** any caller that relied on an un-statused post going live must now pass
23
+ `status:"published"` explicitly. No other tool is affected.
24
+
25
+ ## 3.34.10 — `get_free_slots` accepts ISO dates; `update_form` name fallback for fresh forms
26
+
27
+ Two small fixes that close rough edges found while live-verifying v3.34.9 through
28
+ the MCP tools. Neither changes a proven capability; both have trivial workarounds.
29
+
30
+ - **`get_free_slots` now accepts ISO dates (its documented input) — not just epoch
31
+ milliseconds.** GHL's `GET /calendars/{id}/free-slots` requires `startDate` /
32
+ `endDate` as epoch **milliseconds** and 422s (`startDate must be a number…`) on an
33
+ ISO date, yet the tool's own schema documented `YYYY-MM-DD` and forwarded it
34
+ unconverted — so the documented input always failed. The tool now accepts a bare
35
+ `YYYY-MM-DD` (taken as start-of-day for `startDate`, end-of-day for `endDate`, in
36
+ the supplied `timezone`, default UTC), a full ISO datetime (`Date.parse`), or an
37
+ already-numeric epoch-millis value (passed through, back-compat) and converts to
38
+ the epoch ms GHL needs. Logic extracted to `buildFreeSlotsParams` / `toEpochMillis`
39
+ with tests (incl. the live Phoenix anchor `2026-06-15 → 1781506800000`).
40
+ - **`update_form` no longer throws on the natural `create_form` → `update_form`
41
+ sequence.** When `name` is omitted, `update_form` reads the current name to
42
+ preserve it. A form freshly created by `create_form` has no `name` in its builder
43
+ doc until its first save, so that read returned none and the call threw
44
+ (`Could not resolve current form name`). It now falls back to the public forms list
45
+ (which carries the name) and only errors — with actionable guidance to pass `name`
46
+ — if both sources come up empty. The form-builder tools now also receive the public
47
+ `GHLClient` for this lookup (it already had Firebase auth for the builder routes).
48
+ Helpers `pickFormName` / `findFormNameInList` extracted, with tests.
49
+
3
50
  ## 3.34.9 — Calendar availability: create_calendar / update_calendar now set `openHours`
4
51
 
5
52
  Closes the on-camera "assign availability hours" gap. `create_calendar` and
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.9",
34
+ version: "3.35.0",
35
35
  mcpName: "io.github.drjerryrelth/ghl-command",
36
36
  description: "GoHighLevel MCP Server for Claude. 218 tools \u2014 full CRM, automation, marketing control, account-wide workflow audit, and the only programmatic GHL workflow builder, now multi-tenant across client accounts.",
37
37
  main: "dist/index.js",
@@ -2779,6 +2779,61 @@ function buildUpdateCalendarBody(args) {
2779
2779
  applyCalendarFields(body, args);
2780
2780
  return body;
2781
2781
  }
2782
+ function tzOffsetMillis(utcMillis, timeZone) {
2783
+ const dtf = new Intl.DateTimeFormat("en-US", {
2784
+ timeZone,
2785
+ hourCycle: "h23",
2786
+ year: "numeric",
2787
+ month: "2-digit",
2788
+ day: "2-digit",
2789
+ hour: "2-digit",
2790
+ minute: "2-digit",
2791
+ second: "2-digit"
2792
+ });
2793
+ const parts = dtf.formatToParts(new Date(utcMillis));
2794
+ const get = (type) => Number(parts.find((p) => p.type === type)?.value);
2795
+ const asUTC = Date.UTC(get("year"), get("month") - 1, get("day"), get("hour"), get("minute"), get("second"));
2796
+ return asUTC - utcMillis;
2797
+ }
2798
+ function zonedStartOfDayMillis(year, month, day, timeZone) {
2799
+ const utcGuess = Date.UTC(year, month - 1, day, 0, 0, 0, 0);
2800
+ if (!timeZone) return utcGuess;
2801
+ return utcGuess - tzOffsetMillis(utcGuess, timeZone);
2802
+ }
2803
+ function zonedDayBoundaryMillis(year, month, day, endOfDay, timeZone) {
2804
+ if (endOfDay) return zonedStartOfDayMillis(year, month, day + 1, timeZone) - 1;
2805
+ return zonedStartOfDayMillis(year, month, day, timeZone);
2806
+ }
2807
+ function toEpochMillis(value, opts = {}) {
2808
+ const trimmed = value.trim();
2809
+ if (/^\d+$/.test(trimmed)) {
2810
+ if (trimmed.length === 13) return trimmed;
2811
+ throw new Error(`Ambiguous numeric date "${value}" for free-slots \u2014 pass epoch MILLISECONDS (13 digits), a YYYY-MM-DD date, or a full ISO datetime.`);
2812
+ }
2813
+ const dateOnly = /^(\d{4})-(\d{2})-(\d{2})$/.exec(trimmed);
2814
+ if (dateOnly) {
2815
+ const millis = zonedDayBoundaryMillis(
2816
+ Number(dateOnly[1]),
2817
+ Number(dateOnly[2]),
2818
+ Number(dateOnly[3]),
2819
+ Boolean(opts.endOfDay),
2820
+ opts.timeZone
2821
+ );
2822
+ return String(millis);
2823
+ }
2824
+ const parsed = Date.parse(trimmed);
2825
+ if (!Number.isNaN(parsed)) return String(parsed);
2826
+ throw new Error(`Invalid date "${value}" for free-slots \u2014 use YYYY-MM-DD, a full ISO datetime, or epoch milliseconds.`);
2827
+ }
2828
+ function buildFreeSlotsParams(args) {
2829
+ const params = {
2830
+ startDate: toEpochMillis(args.startDate, { timeZone: args.timezone }),
2831
+ endDate: toEpochMillis(args.endDate, { endOfDay: true, timeZone: args.timezone })
2832
+ };
2833
+ if (args.timezone !== void 0) params.timezone = args.timezone;
2834
+ if (args.userId !== void 0) params.userId = args.userId;
2835
+ return params;
2836
+ }
2782
2837
  function registerCalendarTools(server2, client) {
2783
2838
  safeTool(
2784
2839
  server2,
@@ -2881,15 +2936,13 @@ function registerCalendarTools(server2, client) {
2881
2936
  "Get available free slots for a calendar",
2882
2937
  {
2883
2938
  calendarId: import_zod9.z.string().describe("The calendar ID"),
2884
- startDate: import_zod9.z.string().describe("Start date in ISO format (YYYY-MM-DD)"),
2885
- endDate: import_zod9.z.string().describe("End date in ISO format (YYYY-MM-DD)"),
2939
+ startDate: import_zod9.z.string().describe("Start of the window. Accepts a date (YYYY-MM-DD), a full ISO datetime, or epoch milliseconds. A bare date is taken as start-of-day in `timezone` (UTC if unset). GHL's API needs epoch ms; the tool converts for you."),
2940
+ endDate: import_zod9.z.string().describe("End of the window. Accepts a date (YYYY-MM-DD), a full ISO datetime, or epoch milliseconds. A bare date is taken as end-of-day in `timezone` (UTC if unset)."),
2886
2941
  timezone: import_zod9.z.string().optional().describe("Timezone (e.g. America/New_York)"),
2887
2942
  userId: import_zod9.z.string().optional().describe("Filter by user ID")
2888
2943
  },
2889
2944
  async ({ calendarId, startDate, endDate, timezone, userId }) => {
2890
- const params = { startDate, endDate };
2891
- if (timezone !== void 0) params.timezone = timezone;
2892
- if (userId !== void 0) params.userId = userId;
2945
+ const params = buildFreeSlotsParams({ startDate, endDate, timezone, userId });
2893
2946
  return await client.get(`/calendars/${calendarId}/free-slots`, {
2894
2947
  params
2895
2948
  });
@@ -4001,6 +4054,21 @@ function registerMediaTools(server2, client) {
4001
4054
 
4002
4055
  // src/tools/social-planner.ts
4003
4056
  var import_zod21 = require("zod");
4057
+ function resolvePostStatus(status, scheduledAt) {
4058
+ if (scheduledAt !== void 0 && status !== void 0 && status !== "scheduled") {
4059
+ throw new Error(
4060
+ `create_social_post: scheduledAt was provided but status is "${status}". A scheduled post requires status:"scheduled" (or omit status to auto-schedule).`
4061
+ );
4062
+ }
4063
+ if (status === "scheduled" && scheduledAt === void 0) {
4064
+ throw new Error(
4065
+ 'create_social_post: status:"scheduled" requires scheduledAt (an ISO publish time).'
4066
+ );
4067
+ }
4068
+ if (status !== void 0) return status;
4069
+ if (scheduledAt !== void 0) return "scheduled";
4070
+ return "draft";
4071
+ }
4004
4072
  function registerSocialPlannerTools(server2, client) {
4005
4073
  safeTool(
4006
4074
  server2,
@@ -4072,7 +4140,7 @@ function registerSocialPlannerTools(server2, client) {
4072
4140
  safeTool(
4073
4141
  server2,
4074
4142
  "create_social_post",
4075
- "Create a new social media post",
4143
+ 'Create a social media post. Defaults to a draft (never publishes live) unless you pass status explicitly. To schedule, pass scheduledAt and the status auto-resolves to "scheduled". To post immediately, pass status:"published".',
4076
4144
  {
4077
4145
  locationId: import_zod21.z.string().optional().describe("GHL Location ID (optional if GHL_LOCATION_ID is set)"),
4078
4146
  accountIds: import_zod21.z.array(import_zod21.z.string()).describe("Array of social account IDs to post to"),
@@ -4080,8 +4148,12 @@ function registerSocialPlannerTools(server2, client) {
4080
4148
  summary: import_zod21.z.string().optional().describe("The post text/caption"),
4081
4149
  media: import_zod21.z.array(import_zod21.z.string()).optional().describe("Array of media URLs to attach"),
4082
4150
  type: import_zod21.z.enum(["post", "story", "reel"]).optional().describe("Post type (default post)"),
4083
- status: import_zod21.z.enum(["in_review", "scheduled", "draft", "published"]).optional().describe("Post status"),
4084
- scheduledAt: import_zod21.z.string().optional().describe("Scheduled publish time (ISO string)"),
4151
+ status: import_zod21.z.enum(["in_review", "scheduled", "draft", "published"]).optional().describe(
4152
+ 'Post status. Omit to default to "draft" (safe \u2014 never goes live). Use "published" to post immediately, "scheduled" (with scheduledAt) to schedule, or "in_review" for an approval queue.'
4153
+ ),
4154
+ scheduledAt: import_zod21.z.string().optional().describe(
4155
+ 'Scheduled publish time (ISO string). When set, status auto-resolves to "scheduled" if you omit it; passing any other status alongside scheduledAt is rejected.'
4156
+ ),
4085
4157
  tags: import_zod21.z.array(import_zod21.z.string()).optional().describe("Tags for the post"),
4086
4158
  ogData: import_zod21.z.object({
4087
4159
  title: import_zod21.z.string().optional(),
@@ -4119,7 +4191,7 @@ function registerSocialPlannerTools(server2, client) {
4119
4191
  };
4120
4192
  body.media = media.map((url) => ({ url, type: mimeFor(url) }));
4121
4193
  }
4122
- if (status !== void 0) body.status = status;
4194
+ body.status = resolvePostStatus(status, scheduledAt);
4123
4195
  if (scheduledAt !== void 0) body.scheduleDate = scheduledAt;
4124
4196
  if (tags !== void 0) body.tags = tags;
4125
4197
  if (ogData !== void 0) body.ogData = ogData;
@@ -6007,13 +6079,34 @@ ${text2}`);
6007
6079
 
6008
6080
  // src/tools/form-builder.ts
6009
6081
  var import_zod35 = require("zod");
6082
+ function pickFormName(builderForm) {
6083
+ if (builderForm && typeof builderForm === "object" && "form" in builderForm) {
6084
+ const name = builderForm.form?.name;
6085
+ if (typeof name === "string" && name.length > 0) return name;
6086
+ }
6087
+ return void 0;
6088
+ }
6089
+ function findFormNameInList(list, formId) {
6090
+ const forms = list?.forms;
6091
+ if (Array.isArray(forms)) {
6092
+ for (const f of forms) {
6093
+ if (f && typeof f === "object") {
6094
+ const entry = f;
6095
+ if ((entry.id === formId || entry._id === formId) && typeof entry.name === "string" && entry.name.length > 0) {
6096
+ return entry.name;
6097
+ }
6098
+ }
6099
+ }
6100
+ }
6101
+ return void 0;
6102
+ }
6010
6103
  function buildUpdateFormPath(formId, locationId2) {
6011
6104
  return `/${formId}?locationId=${locationId2}`;
6012
6105
  }
6013
6106
  function buildUpdateFormBody(name, formData) {
6014
6107
  return { name, formData };
6015
6108
  }
6016
- function registerFormBuilderTools(server2, builderClient) {
6109
+ function registerFormBuilderTools(server2, builderClient, publicClient) {
6017
6110
  const client = builderClient;
6018
6111
  if (!client) return;
6019
6112
  async function formRequest(method, path7, body) {
@@ -6067,11 +6160,28 @@ ${text2}`);
6067
6160
  let resolvedName = name;
6068
6161
  if (resolvedName === void 0) {
6069
6162
  const current = await formRequest("GET", `/${formId}?locationId=${client.locationId}`);
6070
- const currentName = typeof current === "object" && current !== null && "form" in current ? current.form?.name : void 0;
6071
- if (typeof currentName !== "string") {
6072
- throw new Error(`Could not resolve current form name for ${formId} (GET /forms/${formId} returned no string name).`);
6163
+ resolvedName = pickFormName(current);
6164
+ if (resolvedName === void 0 && publicClient) {
6165
+ try {
6166
+ const pageSize = 100;
6167
+ const maxForms = 5e3;
6168
+ for (let skip = 0; skip < maxForms; skip += pageSize) {
6169
+ const list = await publicClient.get("/forms/", {
6170
+ params: { locationId: client.locationId, limit: pageSize, skip }
6171
+ });
6172
+ resolvedName = findFormNameInList(list, formId);
6173
+ if (resolvedName !== void 0) break;
6174
+ const forms = list?.forms;
6175
+ if (!Array.isArray(forms) || forms.length < pageSize) break;
6176
+ }
6177
+ } catch {
6178
+ }
6179
+ }
6180
+ if (resolvedName === void 0) {
6181
+ throw new Error(
6182
+ `Could not resolve the current name for form ${formId}. Pass the \`name\` argument explicitly \u2014 a form created via create_form has no builder-doc name until its first save.`
6183
+ );
6073
6184
  }
6074
- resolvedName = currentName;
6075
6185
  }
6076
6186
  const result = await formRequest(
6077
6187
  "POST",
@@ -9545,7 +9655,6 @@ var publicApiTools = [
9545
9655
  var internalApiTools = [
9546
9656
  [registerWorkflowBuilderTools, "workflow-builder"],
9547
9657
  [registerFunnelBuilderTools, "funnel-builder"],
9548
- [registerFormBuilderTools, "form-builder"],
9549
9658
  [registerPipelineBuilderTools, "pipeline-builder"],
9550
9659
  [registerWorkflowClonerTools, "workflow-cloner"],
9551
9660
  [registerSmartListTools, "smart-lists"],
@@ -9558,9 +9667,11 @@ var VALIDATORS_MODULE = "validators";
9558
9667
  var DIAGNOSTICS_MODULE = "diagnostics";
9559
9668
  var LOCATION_SWITCHER_MODULE = "location-switcher";
9560
9669
  var SNAPSHOTS_MODULE = "snapshots";
9670
+ var FORM_BUILDER_MODULE = "form-builder";
9561
9671
  var KNOWN_MODULES = /* @__PURE__ */ new Set([
9562
9672
  ...publicApiTools.map(([, label]) => label),
9563
9673
  ...internalApiTools.map(([, label]) => label),
9674
+ FORM_BUILDER_MODULE,
9564
9675
  VALIDATORS_MODULE,
9565
9676
  DIAGNOSTICS_MODULE,
9566
9677
  LOCATION_SWITCHER_MODULE,
@@ -9578,6 +9689,7 @@ function registerAllTools(server2, client, registry2, mcpVersion, env = process.
9578
9689
  for (const [register, moduleName] of internalApiTools) {
9579
9690
  register(wrap(moduleName), builderClient);
9580
9691
  }
9692
+ registerFormBuilderTools(wrap(FORM_BUILDER_MODULE), builderClient, client);
9581
9693
  registerValidatorTools(wrap(VALIDATORS_MODULE), client, builderClient);
9582
9694
  registerDiagnosticTools(
9583
9695
  wrap(DIAGNOSTICS_MODULE),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@elitedcs/ghl-mcp",
3
- "version": "3.34.9",
3
+ "version": "3.35.0",
4
4
  "mcpName": "io.github.drjerryrelth/ghl-command",
5
5
  "description": "GoHighLevel MCP Server for Claude. 218 tools — full CRM, automation, marketing control, account-wide workflow audit, and the only programmatic GHL workflow builder, now multi-tenant across client accounts.",
6
6
  "main": "dist/index.js",