@elitedcs/ghl-mcp 3.38.0 → 3.40.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 (2) hide show
  1. package/dist/index.js +463 -11
  2. package/package.json +1 -1
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.38.0",
34
+ version: "3.40.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",
@@ -9625,8 +9625,8 @@ var import_zod53 = require("zod");
9625
9625
  var QUESTION_SET_VERSION = "0.1";
9626
9626
  var INTAKE_FIELD_NAME_PREFIX = "Intake: ";
9627
9627
  function deriveFieldKey(fieldName) {
9628
- const slug2 = fieldName.toLowerCase().replace(/[^a-z0-9 ]+/g, "").replace(/ /g, "_");
9629
- return `contact.${slug2}`;
9628
+ const slug3 = fieldName.toLowerCase().replace(/[^a-z0-9 ]+/g, "").replace(/ /g, "_");
9629
+ return `contact.${slug3}`;
9630
9630
  }
9631
9631
  function intakeFieldName(label) {
9632
9632
  return `${INTAKE_FIELD_NAME_PREFIX}${label}`;
@@ -10225,6 +10225,101 @@ function buildIntakeFormData(opts) {
10225
10225
  };
10226
10226
  }
10227
10227
 
10228
+ // src/intake-to-build/plan-form.ts
10229
+ function slug2(s) {
10230
+ return s.toLowerCase().split(/[^a-z0-9]+/).filter(Boolean).join("_");
10231
+ }
10232
+ function humanizeKey(key) {
10233
+ return key.split(/[^a-z0-9]+/i).filter(Boolean).map((w) => w.charAt(0).toUpperCase() + w.slice(1)).join(" ");
10234
+ }
10235
+ function buildPlanStandardField(key, required) {
10236
+ const isEmail = key === "email";
10237
+ const isPhone = key === "phone";
10238
+ const label = humanizeKey(key);
10239
+ const base = {
10240
+ fieldWidthPercentage: 100,
10241
+ hiddenFieldQueryKey: key,
10242
+ label,
10243
+ placeholder: label,
10244
+ required,
10245
+ standard: true,
10246
+ tag: key,
10247
+ type: isEmail ? "email" : "text",
10248
+ typeLabel: isEmail ? "Email" : isPhone ? "Phone" : "Text"
10249
+ };
10250
+ if (isPhone) base.enableCountryPicker = false;
10251
+ return base;
10252
+ }
10253
+ function buildPlanCustomField(record, required, locationId2, position) {
10254
+ const type = formFieldType(record.dataType);
10255
+ const field = {
10256
+ Id: record.id,
10257
+ id: record.id,
10258
+ tag: record.id,
10259
+ active: false,
10260
+ allowCustomOption: false,
10261
+ customFieldLabel: record.name,
10262
+ dataType: record.dataType,
10263
+ dateAdded: record.dateAdded ?? "",
10264
+ description: "",
10265
+ documentType: "field",
10266
+ edit: false,
10267
+ fieldKey: record.fieldKey,
10268
+ fieldWidthPercentage: 100,
10269
+ fieldsCount: 0,
10270
+ hiddenFieldQueryKey: slug2(record.name),
10271
+ label: record.name,
10272
+ locationId: locationId2,
10273
+ model: record.model ?? "contact",
10274
+ name: record.name,
10275
+ parentId: record.parentId ?? "",
10276
+ placeholder: "",
10277
+ position,
10278
+ required,
10279
+ showInForms: true,
10280
+ standard: false,
10281
+ type
10282
+ };
10283
+ const options = record.picklistOptions ?? void 0;
10284
+ if (type === "single_options" || type === "multiple_options" || type === "checkbox") {
10285
+ field.picklistOptions = options ? [...options] : [];
10286
+ }
10287
+ if (type === "multiple_options") {
10288
+ field.calculatedOptions = (options ?? []).map((label) => ({ calculatedValue: "", label }));
10289
+ field.category = "choiceElements";
10290
+ field.typeLabel = "Multi Dropdown";
10291
+ }
10292
+ if (type === "phone") field.enableCountryPicker = false;
10293
+ return field;
10294
+ }
10295
+ function buildPlanFormData(fields, locationId2) {
10296
+ const out = [];
10297
+ let position = 0;
10298
+ for (const f of fields) {
10299
+ if (f.kind === "standard") {
10300
+ out.push(buildPlanStandardField(f.key, f.required));
10301
+ } else {
10302
+ out.push(buildPlanCustomField(f.record, f.required, locationId2, position += 50));
10303
+ }
10304
+ }
10305
+ out.push(buildSubmitButton("Submit"));
10306
+ return {
10307
+ autoResponder: false,
10308
+ emailNotifications: false,
10309
+ form: {
10310
+ fields: out,
10311
+ formLabelVisible: true,
10312
+ formAction: {
10313
+ actionType: "2",
10314
+ headerImageSrc: "",
10315
+ mobileHeaderImageSrc: "",
10316
+ redirectUrl: "",
10317
+ thankyouText: "<p style='text-align:center;margin:0;'>Thanks! We received your submission.</p>"
10318
+ }
10319
+ }
10320
+ };
10321
+ }
10322
+
10228
10323
  // src/intake-to-build/brief.ts
10229
10324
  var import_zod51 = require("zod");
10230
10325
  var BRIEF_SCHEMA_VERSION = "0.1";
@@ -10777,6 +10872,26 @@ function checkRefIntegrity(plan, defined) {
10777
10872
  for (const b of plan.buildOrder ?? []) checkMaybeWildcard(b, "buildOrder");
10778
10873
  return { errors, scanned };
10779
10874
  }
10875
+ var KNOWN_STANDARD_FORM_KEYS = /* @__PURE__ */ new Set([
10876
+ "first_name",
10877
+ "last_name",
10878
+ "name",
10879
+ "full_name",
10880
+ "email",
10881
+ "phone",
10882
+ "address1",
10883
+ "address",
10884
+ "city",
10885
+ "state",
10886
+ "postal_code",
10887
+ "country",
10888
+ "website",
10889
+ "organization",
10890
+ "company_name",
10891
+ "date_of_birth",
10892
+ "contact_source",
10893
+ "source"
10894
+ ]);
10780
10895
  function validateBuildPlan(input) {
10781
10896
  const parsed = buildPlanSchema.safeParse(input);
10782
10897
  if (!parsed.success) {
@@ -10806,7 +10921,51 @@ function validateBuildPlan(input) {
10806
10921
  seen.add(key);
10807
10922
  }
10808
10923
  }
10924
+ for (const fn of plan.funnels ?? []) {
10925
+ const seen = /* @__PURE__ */ new Set();
10926
+ for (const pg of fn.pages) {
10927
+ const key = pg.name.trim().toLowerCase();
10928
+ if (seen.has(key)) {
10929
+ allErrors.push(
10930
+ `funnel "${fn.ref}" has duplicate page name "${pg.name}" \u2014 page names must be unique within a funnel so each page ref resolves to a single step`
10931
+ );
10932
+ }
10933
+ seen.add(key);
10934
+ }
10935
+ }
10936
+ const nameGroups = [
10937
+ ["pipelines", (plan.pipelines ?? []).map((x) => ({ ref: x.ref, name: x.name }))],
10938
+ ["customFields", (plan.customFields ?? []).map((x) => ({ ref: x.ref, name: x.name }))],
10939
+ ["tags", (plan.tags ?? []).map((x) => ({ ref: x.ref, name: x.name }))],
10940
+ ["customValues", (plan.customValues ?? []).map((x) => ({ ref: x.ref, name: x.name }))],
10941
+ ["calendars", (plan.calendars ?? []).map((x) => ({ ref: x.ref, name: x.name }))],
10942
+ ["forms", (plan.forms ?? []).map((x) => ({ ref: x.ref, name: x.name }))],
10943
+ ["funnels", (plan.funnels ?? []).map((x) => ({ ref: x.ref, name: x.name }))]
10944
+ ];
10945
+ for (const [type, objs] of nameGroups) {
10946
+ const seen = /* @__PURE__ */ new Map();
10947
+ for (const o of objs) {
10948
+ const key = o.name.trim().toLowerCase();
10949
+ const prev = seen.get(key);
10950
+ if (prev) {
10951
+ allErrors.push(
10952
+ `${type} "${o.ref}" and "${prev}" share the name "${o.name}" \u2014 names must be unique within a type (the executor binds same-named objects by name, so duplicates would collapse onto one real id)`
10953
+ );
10954
+ } else {
10955
+ seen.set(key, o.ref);
10956
+ }
10957
+ }
10958
+ }
10809
10959
  const warnings = [];
10960
+ for (const fm of plan.forms ?? []) {
10961
+ for (const fl of fm.fields) {
10962
+ if (fl.type === "standard" && !KNOWN_STANDARD_FORM_KEYS.has(fl.key.trim().toLowerCase())) {
10963
+ warnings.push(
10964
+ `forms "${fm.ref}" standard field key "${fl.key}" is not a recognized GHL standard contact field \u2014 GHL may not save it; if this is custom data, define a custom field and reference it with a custom fieldRef`
10965
+ );
10966
+ }
10967
+ }
10968
+ }
10810
10969
  for (const p of plan.pipelines ?? []) {
10811
10970
  const positions = p.stages.map((s) => s.position).sort((a, b) => a - b);
10812
10971
  const expected = positions.every((pos, idx) => pos === idx);
@@ -11290,6 +11449,10 @@ function renderReport(plan, result, ctx) {
11290
11449
  any = true;
11291
11450
  L.push(` \u2022 [calendar] ${c.reason}`);
11292
11451
  }
11452
+ for (const fn of plan.funnels ?? []) {
11453
+ any = true;
11454
+ L.push(` \u2022 [funnel] Design + populate the pages of funnel "${fn.name}" (Blueprint builds the funnel + steps; page content/design is manual).`);
11455
+ }
11293
11456
  for (const w of result.workflows) {
11294
11457
  for (const m of w.manual) {
11295
11458
  any = true;
@@ -11312,6 +11475,9 @@ function renderReport(plan, result, ctx) {
11312
11475
  // src/intake-to-build/execute.ts
11313
11476
  var norm2 = (s) => s.trim().toLowerCase();
11314
11477
  var sleep = (ms) => new Promise((r) => setTimeout(r, ms));
11478
+ function slugifyName(s) {
11479
+ return s.toLowerCase().split(/[^a-z0-9]+/).filter(Boolean).join("-");
11480
+ }
11315
11481
  async function executeBackbone(plan, deps, opts = {}) {
11316
11482
  const retries = opts.verifyRetries ?? 4;
11317
11483
  const backoff = opts.verifyBackoffMs ?? 500;
@@ -11465,6 +11631,140 @@ async function executeBackbone(plan, deps, opts = {}) {
11465
11631
  idMap[cal.ref] = verified.id;
11466
11632
  built.push({ ref: cal.ref, type: "calendar", name: cal.name, status: "created", realId: verified.id });
11467
11633
  }
11634
+ let cfRecords;
11635
+ for (const form of plan.forms ?? []) {
11636
+ let forms;
11637
+ try {
11638
+ forms = await deps.listForms();
11639
+ } catch (e) {
11640
+ return halt(form.ref, "form", `could not read existing forms: ${msg(e)}`);
11641
+ }
11642
+ const matches = forms.filter((f) => norm2(f.name) === norm2(form.name));
11643
+ if (matches.length > 1) {
11644
+ return halt(form.ref, "form", `${matches.length} existing forms are named "${form.name}" \u2014 ambiguous, cannot safely bind ${form.ref}. Resolve the duplicate in GHL, then re-run.`);
11645
+ }
11646
+ if (matches.length === 1) {
11647
+ idMap[form.ref] = matches[0].id;
11648
+ built.push({ ref: form.ref, type: "form", name: form.name, status: "existing", realId: matches[0].id });
11649
+ continue;
11650
+ }
11651
+ if (!cfRecords) {
11652
+ try {
11653
+ cfRecords = await deps.listCustomFieldRecords();
11654
+ } catch (e) {
11655
+ return halt(form.ref, "form", `could not read custom fields to resolve form refs: ${msg(e)}`);
11656
+ }
11657
+ }
11658
+ const byId = new Map(cfRecords.map((r) => [r.id, r]));
11659
+ const resolved = [];
11660
+ let fieldHalt2;
11661
+ for (const ff of form.fields) {
11662
+ if (ff.type === "standard") {
11663
+ resolved.push({ kind: "standard", key: ff.key, required: ff.required ?? false });
11664
+ continue;
11665
+ }
11666
+ const realId = idMap[ff.fieldRef];
11667
+ if (!realId) {
11668
+ fieldHalt2 = halt(form.ref, "form", `form "${form.name}" references ${ff.fieldRef} but that custom field was not built/resolved \u2014 cannot build the form`);
11669
+ break;
11670
+ }
11671
+ const rec = byId.get(realId);
11672
+ if (!rec) {
11673
+ fieldHalt2 = halt(form.ref, "form", `custom field ${ff.fieldRef} (id ${realId}) not found in this account when building form "${form.name}"`);
11674
+ break;
11675
+ }
11676
+ resolved.push({ kind: "custom", record: rec, required: ff.required ?? false });
11677
+ }
11678
+ if (fieldHalt2) return fieldHalt2;
11679
+ let formId;
11680
+ try {
11681
+ formId = await deps.createForm(form.name, resolved);
11682
+ } catch (e) {
11683
+ return halt(form.ref, "form", `create failed: ${msg(e)}`);
11684
+ }
11685
+ if (!formId) return halt(form.ref, "form", "form create returned no id");
11686
+ idMap[form.ref] = formId;
11687
+ built.push({ ref: form.ref, type: "form", name: form.name, status: "created", realId: formId });
11688
+ }
11689
+ const formNameByRef = new Map((plan.forms ?? []).map((f) => [f.ref, f.name]));
11690
+ const calNameByRef = new Map((plan.calendars ?? []).map((c) => [c.ref, c.name]));
11691
+ const funnelContentReason = (fn) => {
11692
+ const pageBits = fn.pages.map((pg) => {
11693
+ const hosts = [];
11694
+ if (pg.formRef) hosts.push(`hosts form "${formNameByRef.get(pg.formRef) ?? pg.formRef}"`);
11695
+ if (pg.calendarRef) hosts.push(`links calendar "${calNameByRef.get(pg.calendarRef) ?? pg.calendarRef}"`);
11696
+ return `page "${pg.name}"${hosts.length ? ` (${hosts.join(", ")})` : ""}`;
11697
+ });
11698
+ return `Design + publish the pages of funnel "${fn.name}": ${pageBits.join("; ")}. (Blueprint built the funnel + steps; page content/design is manual.)`;
11699
+ };
11700
+ for (const fn of plan.funnels ?? []) {
11701
+ let funnels;
11702
+ try {
11703
+ funnels = await deps.listFunnels();
11704
+ } catch (e) {
11705
+ return halt(fn.ref, "funnel", `could not read existing funnels: ${msg(e)}`);
11706
+ }
11707
+ const matches = funnels.filter((f) => norm2(f.name) === norm2(fn.name));
11708
+ if (matches.length > 1) {
11709
+ return halt(fn.ref, "funnel", `${matches.length} existing funnels are named "${fn.name}" \u2014 ambiguous, cannot safely bind ${fn.ref}. Resolve the duplicate in GHL, then re-run.`);
11710
+ }
11711
+ if (matches.length === 1) {
11712
+ const ex = matches[0];
11713
+ const pageBindings = [];
11714
+ const problems = [];
11715
+ for (const pg of fn.pages) {
11716
+ const stepMatches = ex.steps.filter((s) => norm2(s.name) === norm2(pg.name));
11717
+ if (stepMatches.length === 1) pageBindings.push({ ref: pg.ref, name: pg.name, id: stepMatches[0].id });
11718
+ else if (stepMatches.length === 0) problems.push(`page "${pg.name}" has no matching step`);
11719
+ else problems.push(`page "${pg.name}" matches ${stepMatches.length} steps`);
11720
+ }
11721
+ if (problems.length) {
11722
+ return halt(fn.ref, "funnel", `a funnel named "${fn.name}" already exists but its steps don't match the plan: ${problems.join("; ")}. Align the steps in GHL or remove the funnel, then re-run. (Blueprint never modifies an existing funnel.)`);
11723
+ }
11724
+ idMap[fn.ref] = ex.id;
11725
+ built.push({ ref: fn.ref, type: "funnel", name: fn.name, status: "existing", realId: ex.id });
11726
+ for (const b of pageBindings) {
11727
+ idMap[b.ref] = b.id;
11728
+ built.push({ ref: b.ref, type: "page", name: b.name, status: "existing", realId: b.id });
11729
+ }
11730
+ manual.push({ ref: fn.ref, type: "funnel-page-content", name: fn.name, reason: funnelContentReason(fn) });
11731
+ continue;
11732
+ }
11733
+ let funnelId;
11734
+ try {
11735
+ funnelId = await deps.createFunnel(fn.name);
11736
+ } catch (e) {
11737
+ return halt(fn.ref, "funnel", `create failed: ${msg(e)}`);
11738
+ }
11739
+ if (!funnelId) return halt(fn.ref, "funnel", "funnel create returned no id");
11740
+ const stepBindings = [];
11741
+ try {
11742
+ for (const pg of fn.pages) {
11743
+ const stepId = await deps.createFunnelStep(funnelId, pg.name, `/${slugifyName(pg.name)}`);
11744
+ if (!stepId) throw new Error(`step "${pg.name}" returned no id`);
11745
+ stepBindings.push({ ref: pg.ref, name: pg.name, id: stepId });
11746
+ }
11747
+ } catch (e) {
11748
+ let rolledBack = false;
11749
+ try {
11750
+ await deps.deleteFunnel(funnelId);
11751
+ rolledBack = true;
11752
+ } catch {
11753
+ }
11754
+ return halt(
11755
+ fn.ref,
11756
+ "funnel",
11757
+ rolledBack ? `step create failed; the partial funnel was rolled back (deleted): ${msg(e)}` : `step create failed AND the rollback delete also failed \u2014 manually delete the orphan funnel (id ${funnelId}) in GHL before re-running: ${msg(e)}`
11758
+ );
11759
+ }
11760
+ idMap[fn.ref] = funnelId;
11761
+ built.push({ ref: fn.ref, type: "funnel", name: fn.name, status: "created", realId: funnelId });
11762
+ for (const sb of stepBindings) {
11763
+ idMap[sb.ref] = sb.id;
11764
+ built.push({ ref: sb.ref, type: "page", name: sb.name, status: "created", realId: sb.id });
11765
+ }
11766
+ manual.push({ ref: fn.ref, type: "funnel-page-content", name: fn.name, reason: funnelContentReason(fn) });
11767
+ }
11468
11768
  return { ok: true, idMap, built, manual, deferred: deferredSections(plan) };
11469
11769
  }
11470
11770
  async function buildSimple(objects, type, list, create, nameOf, pollForNew, idMap, built) {
@@ -11501,8 +11801,6 @@ async function buildSimple(objects, type, list, create, nameOf, pollForNew, idMa
11501
11801
  }
11502
11802
  function deferredSections(plan) {
11503
11803
  const out = [];
11504
- if (plan.forms?.length) out.push({ section: "forms", count: plan.forms.length });
11505
- if (plan.funnels?.length) out.push({ section: "funnels", count: plan.funnels.length });
11506
11804
  if (plan.workflows?.length) out.push({ section: "workflows", count: plan.workflows.length });
11507
11805
  return out;
11508
11806
  }
@@ -11639,6 +11937,38 @@ function pickPipelines(raw) {
11639
11937
  }
11640
11938
  return out;
11641
11939
  }
11940
+ function pickFunnels(raw) {
11941
+ const root = raw && typeof raw === "object" ? raw : {};
11942
+ const list = Array.isArray(root.funnels) ? root.funnels : Array.isArray(raw) ? raw : [];
11943
+ const out = [];
11944
+ for (const item of list) {
11945
+ if (!item || typeof item !== "object") continue;
11946
+ const f = item;
11947
+ const id = typeof f._id === "string" ? f._id : typeof f.id === "string" ? f.id : void 0;
11948
+ const name = typeof f.name === "string" ? f.name : void 0;
11949
+ if (!id || !name) continue;
11950
+ const stepsRaw = Array.isArray(f.steps) ? f.steps : [];
11951
+ const steps = stepsRaw.filter((s) => !!s && typeof s === "object").map((s) => ({
11952
+ id: typeof s._id === "string" ? s._id : typeof s.id === "string" ? s.id : "",
11953
+ name: typeof s.name === "string" ? s.name : ""
11954
+ })).filter((s) => s.id && s.name);
11955
+ out.push({ id, name, steps });
11956
+ }
11957
+ return out;
11958
+ }
11959
+ function extractFunnelId(result) {
11960
+ if (!result || typeof result !== "object") return void 0;
11961
+ const r = result;
11962
+ if (typeof r._id === "string") return r._id;
11963
+ if (typeof r.id === "string") return r.id;
11964
+ const funnel = r.funnel;
11965
+ if (funnel && typeof funnel === "object") {
11966
+ const f = funnel;
11967
+ if (typeof f._id === "string") return f._id;
11968
+ if (typeof f.id === "string") return f.id;
11969
+ }
11970
+ return void 0;
11971
+ }
11642
11972
  function makeExecuteDeps(client, builderClient, locationId2) {
11643
11973
  const pipelineApi = async (method, path7, body) => {
11644
11974
  const headers = await builderClient.buildHeaders();
@@ -11649,6 +11979,27 @@ function makeExecuteDeps(client, builderClient, locationId2) {
11649
11979
  if (!response.ok) {
11650
11980
  const text2 = await response.text();
11651
11981
  throw new Error(`Pipeline API ${response.status}: ${method} ${path7}
11982
+ ${text2.slice(0, 300)}`);
11983
+ }
11984
+ const text = await response.text();
11985
+ if (!text) return {};
11986
+ try {
11987
+ return JSON.parse(text);
11988
+ } catch {
11989
+ return JSON.parse(text.replace(/[\x00-\x1F\x7F]/g, ""));
11990
+ }
11991
+ };
11992
+ const funnelApi = async (method, path7, body) => {
11993
+ const headers = await builderClient.buildHeaders();
11994
+ headers.Origin = "https://app.gohighlevel.com";
11995
+ headers.Referer = "https://app.gohighlevel.com/";
11996
+ const url = `https://backend.leadconnectorhq.com/funnels${path7}`;
11997
+ const options = { method, headers };
11998
+ if (body && (method === "POST" || method === "PUT")) options.body = JSON.stringify(body);
11999
+ const response = await fetch(url, options);
12000
+ if (!response.ok) {
12001
+ const text2 = await response.text();
12002
+ throw new Error(`Funnel API ${response.status}: ${method} ${path7}
11652
12003
  ${text2.slice(0, 300)}`);
11653
12004
  }
11654
12005
  const text = await response.text();
@@ -11700,6 +12051,107 @@ ${text2.slice(0, 300)}`);
11700
12051
  locationId2
11701
12052
  );
11702
12053
  await client.post("/calendars/", { body, noRetry: true });
12054
+ },
12055
+ // Paginate the form list: a single limit:100 page would miss the target (or
12056
+ // an orphan) form on accounts with many forms, breaking NEVER-CLOBBER (the
12057
+ // executor would create a duplicate). Mirrors the update_form name-lookup scan.
12058
+ listForms: async () => {
12059
+ const out = [];
12060
+ const pageSize = 100;
12061
+ const maxForms = 5e3;
12062
+ for (let skip = 0; skip < maxForms; skip += pageSize) {
12063
+ const page = pickObjects(await client.get("/forms/", { params: { locationId: locationId2, limit: pageSize, skip } }), ["forms"]);
12064
+ out.push(...page);
12065
+ if (page.length < pageSize) break;
12066
+ }
12067
+ return out;
12068
+ },
12069
+ listCustomFieldRecords: async () => parseCustomFields(await client.get(`/locations/${locationId2}/customFields`)),
12070
+ createForm: async (name, fields) => {
12071
+ const formData = buildPlanFormData(fields, locationId2);
12072
+ const createResult = await formApiRequest(builderClient, "POST", `/?locationId=${locationId2}`, {
12073
+ name,
12074
+ locationId: locationId2,
12075
+ formData: { form: { fields: [], formLabelVisible: true } }
12076
+ });
12077
+ const formId = extractFormId(createResult);
12078
+ if (!formId) {
12079
+ throw new Error(`create_form returned no id: ${JSON.stringify(createResult).slice(0, 200)}`);
12080
+ }
12081
+ try {
12082
+ for (let attempt = 1; ; attempt++) {
12083
+ try {
12084
+ await formApiRequest(builderClient, "POST", `/${formId}?locationId=${locationId2}`, { name, formData });
12085
+ break;
12086
+ } catch (saveErr) {
12087
+ if (isFormNotYetPropagated(saveErr) && attempt < 6) {
12088
+ await sleep2(700 * attempt);
12089
+ continue;
12090
+ }
12091
+ throw saveErr;
12092
+ }
12093
+ }
12094
+ let persisted = 0;
12095
+ for (let attempt = 1; attempt <= 6; attempt++) {
12096
+ const verify = await formApiRequest(builderClient, "GET", `/${formId}?locationId=${locationId2}`);
12097
+ persisted = countFormFields(verify);
12098
+ if (persisted > 0) break;
12099
+ if (attempt < 6) await sleep2(700 * attempt);
12100
+ }
12101
+ if (persisted === 0) {
12102
+ throw new Error(`form "${name}" was created (${formId}) but no fields persisted after save (read-after-write); not binding`);
12103
+ }
12104
+ return formId;
12105
+ } catch (err) {
12106
+ try {
12107
+ await formApiRequest(builderClient, "DELETE", `/${formId}?locationId=${locationId2}`);
12108
+ } catch {
12109
+ }
12110
+ throw err;
12111
+ }
12112
+ },
12113
+ // ── Funnels ────────────────────────────────────────────────────────────
12114
+ // Internal funnel API. Origin/Referer are REQUIRED on writes (else 401 "Error
12115
+ // calling IAM service") — mirrors funnel-builder.ts.
12116
+ // Paginate so never-clobber sees ALL funnels (a single page would miss a
12117
+ // same-named funnel beyond it → duplicate). Dedup by id and stop when a page
12118
+ // adds nothing new — robust whether or not the endpoint honors `offset`.
12119
+ listFunnels: async () => {
12120
+ const byId = /* @__PURE__ */ new Map();
12121
+ const pageSize = 100;
12122
+ const maxFunnels = 5e3;
12123
+ for (let offset = 0; offset < maxFunnels; offset += pageSize) {
12124
+ const page = pickFunnels(await funnelApi("GET", `/funnel/list?locationId=${locationId2}&limit=${pageSize}&offset=${offset}&getStats=true`));
12125
+ let added = 0;
12126
+ for (const f of page) if (!byId.has(f.id)) {
12127
+ byId.set(f.id, f);
12128
+ added++;
12129
+ }
12130
+ if (page.length < pageSize || added === 0) break;
12131
+ }
12132
+ return [...byId.values()];
12133
+ },
12134
+ createFunnel: async (name) => {
12135
+ const result = await funnelApi("POST", `/funnel/create?locationId=${locationId2}`, {
12136
+ name,
12137
+ locationId: locationId2,
12138
+ type: "funnel",
12139
+ steps: []
12140
+ });
12141
+ const id = extractFunnelId(result);
12142
+ if (!id) throw new Error(`create_funnel returned no id: ${JSON.stringify(result).slice(0, 200)}`);
12143
+ return id;
12144
+ },
12145
+ createFunnelStep: async (funnelId, name, url) => {
12146
+ const stepId = crypto.randomUUID();
12147
+ await funnelApi("POST", `/funnel/create-step`, {
12148
+ funnelId,
12149
+ step: { id: stepId, name, url, pages: [], control_traffic: 100, split: false, type: "optin_funnel_page" }
12150
+ });
12151
+ return stepId;
12152
+ },
12153
+ deleteFunnel: async (funnelId) => {
12154
+ await funnelApi("POST", `/funnel/delete`, { funnelId, locationId: locationId2, userId: builderClient.getUserId() });
11703
12155
  }
11704
12156
  };
11705
12157
  }
@@ -11758,7 +12210,7 @@ function registerIntakeToBuildTools(server2, client, builderClient) {
11758
12210
  );
11759
12211
  server2.tool(
11760
12212
  "apply_build_plan",
11761
- `Take an APPROVED Blueprint \xA75 build plan + the CURRENT confirmed sub-account and build it. mode:"dry_run" (default) writes NOTHING \u2014 it resolves refs, expands each workflow's logical actions to native GHL JSON, runs the NEVER-CLOBBER existing-asset scan, and returns a two-part report. Run it FIRST. mode:"execute" performs LIVE writes for the CRM backbone (pipelines+stages, custom fields, tags, custom values) AND calendars: never clobbers (same-named objects are bound to their existing id, never modified), verifies each create by read-back before resolving its ref, halts on the first failure returning the partial idMap, and is idempotent (re-run = no-op). Staff-requiring calendars (round_robin etc.) auto-build only when you are the sole account user (auto-assigns you as the team member); with 0 or 2+ users they're surfaced as a manual step, not auto-staffed to a guess. Forms/funnels/workflows are surfaced as manual next steps, not auto-built yet. Always confirms the active location and validates the plan before any write.`,
12213
+ `Take an APPROVED Blueprint \xA75 build plan + the CURRENT confirmed sub-account and build it. mode:"dry_run" (default) writes NOTHING \u2014 it resolves refs, expands each workflow's logical actions to native GHL JSON, runs the NEVER-CLOBBER existing-asset scan, and returns a two-part report. Run it FIRST. mode:"execute" performs LIVE writes for the CRM backbone (pipelines+stages, custom fields, tags, custom values), calendars, AND forms: never clobbers (same-named objects are bound to their existing id, never modified), verifies each create by read-back before resolving its ref, halts on the first failure returning the partial idMap, and is idempotent (re-run = no-op). Staff-requiring calendars (round_robin etc.) auto-build only when you are the sole account user (auto-assigns you as the team member); with 0 or 2+ users they're surfaced as a manual step, not auto-staffed to a guess. Forms build with their standard + custom fields (custom fieldRefs resolve to the real fields created earlier in the run). Funnels build structurally (funnel + named steps), with each funnel emitting a manual 'design + populate the pages' step (page content/HTML is not auto-built \u2014 plans carry outlines). Workflows are surfaced as manual next steps, not auto-built yet. Always confirms the active location and validates the plan before any write.`,
11762
12214
  {
11763
12215
  plan: import_zod53.z.record(import_zod53.z.unknown()).describe("The approved \xA75 Build Plan object."),
11764
12216
  mode: import_zod53.z.enum(["dry_run", "execute"]).optional().describe("dry_run (default) = resolve/expand/scan/report, no writes. execute = live writes (not yet enabled)."),
@@ -11833,7 +12285,7 @@ function registerIntakeToBuildTools(server2, client, builderClient) {
11833
12285
  }
11834
12286
  const deps = makeExecuteDeps(client, builderClient, activeLocation);
11835
12287
  const exec = await executeBackbone(typedPlan, deps);
11836
- const calendarManualLines = exec.manual.map((m) => `[calendar] ${m.reason}`);
12288
+ const execManualLines = exec.manual.map((m) => `[${m.type}] ${m.reason}`);
11837
12289
  const manualLines = result.workflows.flatMap((w) => [
11838
12290
  ...w.manual.map((m) => `[${w.name}] ${m.reason}`),
11839
12291
  ...w.needsContent.map((c) => `[${w.name}] ${c.reason}`)
@@ -11851,9 +12303,9 @@ function registerIntakeToBuildTools(server2, client, builderClient) {
11851
12303
  manual: exec.manual,
11852
12304
  idMap: exec.idMap,
11853
12305
  deferred: exec.deferred,
11854
- deferredNote: "execute builds the CRM backbone (pipelines, custom fields, tags, custom values) and calendars live. Staff-requiring calendars (round_robin etc.) auto-build only when you are the sole user; otherwise they're listed under manual steps. Forms/funnels/workflows are planned but NOT auto-built yet \u2014 create them via the GHL UI or the dedicated tools, in this order: forms \u2192 funnels \u2192 workflows.",
11855
- nextManualSteps: [...calendarManualLines, ...manualLines, ...handoffLines],
11856
- summary: exec.ok ? `Built ${exec.built.filter((b) => b.status === "created").length} new object(s), bound ${exec.built.filter((b) => b.status === "existing").length} existing.${exec.manual.length ? ` ${exec.manual.length} calendar(s) need manual staff assignment.` : ""}${exec.deferred.length ? " Deferred: " + exec.deferred.map((d) => `${d.count} ${d.section}`).join(", ") + " (manual)." : ""}` : `HALTED at ${exec.halted?.atRef} (${exec.halted?.reason}). ${exec.built.length} object(s) were created before the halt \u2014 see idMap to resume or clean up. NEVER-CLOBBER means a re-run will bind those, not duplicate them.`
12306
+ deferredNote: "execute builds the CRM backbone (pipelines, custom fields, tags, custom values), calendars, forms, and funnels (funnel + named steps) live. Staff-requiring calendars (round_robin etc.) auto-build only when you are the sole user, else manual. Funnel PAGE CONTENT/design is not auto-built (plans carry outlines, not HTML) \u2014 each funnel lists a manual 'design + populate the pages' step. Workflows are planned but NOT auto-built yet.",
12307
+ nextManualSteps: [...execManualLines, ...manualLines, ...handoffLines],
12308
+ summary: exec.ok ? `Built ${exec.built.filter((b) => b.status === "created").length} new object(s), bound ${exec.built.filter((b) => b.status === "existing").length} existing.${exec.manual.length ? ` ${exec.manual.length} item(s) need a manual step (see nextManualSteps).` : ""}${exec.deferred.length ? " Deferred: " + exec.deferred.map((d) => `${d.count} ${d.section}`).join(", ") + " (manual)." : ""}` : `HALTED at ${exec.halted?.atRef} (${exec.halted?.reason}). ${exec.built.length} object(s) were created before the halt \u2014 see idMap to resume or clean up. NEVER-CLOBBER means a re-run will bind those, not duplicate them.`
11857
12309
  });
11858
12310
  }
11859
12311
  const collisions = result.items.filter((i) => i.status === "existing");
@@ -11891,7 +12343,7 @@ function registerIntakeToBuildTools(server2, client, builderClient) {
11891
12343
  calendarsManual: result.calendarsManual,
11892
12344
  handoffs: result.handoffs,
11893
12345
  report,
11894
- next: 'Review the report. When it looks right, re-run with mode:"execute" to build the CRM backbone + calendars live (pipelines, fields, tags, custom values, calendars). Forms/funnels/workflows are listed as manual next steps.'
12346
+ next: 'Review the report. When it looks right, re-run with mode:"execute" to build live: the CRM backbone (pipelines, fields, tags, custom values), calendars, forms, and funnels (funnel + named steps). Funnel page content/design and workflows are listed as manual next steps.'
11895
12347
  });
11896
12348
  } catch (error) {
11897
12349
  return errorResponse(error);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@elitedcs/ghl-mcp",
3
- "version": "3.38.0",
3
+ "version": "3.40.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",