@elitedcs/ghl-mcp 3.39.0 → 3.41.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/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.39.0",
34
+ version: "3.41.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",
@@ -705,6 +705,12 @@ function decodeFirebaseClaims(idToken) {
705
705
  }
706
706
  }
707
707
 
708
+ // src/id-shape.ts
709
+ var GHL_ID_SHAPE = /^([A-Za-z0-9]{17,}|[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12})$/;
710
+ function isIdShaped(s) {
711
+ return typeof s === "string" && GHL_ID_SHAPE.test(s);
712
+ }
713
+
708
714
  // src/trigger-schemas.ts
709
715
  var import_zod3 = require("zod");
710
716
  var TriggerActionSchema = import_zod3.z.object({
@@ -1170,6 +1176,31 @@ function normalizeRemoveFromWorkflowAction(action) {
1170
1176
  }
1171
1177
  return { ...action, attributes: next };
1172
1178
  }
1179
+ function normalizeInternalUpdateOpportunityAction(action) {
1180
+ if (action.type !== "internal_update_opportunity") return action;
1181
+ const attrIn = action.attributes && typeof action.attributes === "object" ? action.attributes : {};
1182
+ const fieldsIn = Array.isArray(attrIn.__customInputFields__) ? attrIn.__customInputFields__ : [];
1183
+ const __customInputFields__ = fieldsIn.map((raw) => {
1184
+ const f = raw && typeof raw === "object" ? raw : {};
1185
+ return {
1186
+ ...f,
1187
+ // shape-preserving — keep every key a real node carries
1188
+ __customInputs__: f.__customInputs__ && typeof f.__customInputs__ === "object" ? f.__customInputs__ : {},
1189
+ dataType: typeof f.dataType === "string" ? f.dataType : "SINGLE_OPTIONS",
1190
+ valueFieldType: typeof f.valueFieldType === "string" ? f.valueFieldType : "select"
1191
+ };
1192
+ });
1193
+ const attributes = {
1194
+ ...attrIn,
1195
+ // shape-preserving
1196
+ type: "internal_update_opportunity",
1197
+ allowBackward: typeof attrIn.allowBackward === "boolean" ? attrIn.allowBackward : false,
1198
+ __customInputs__: attrIn.__customInputs__ && typeof attrIn.__customInputs__ === "object" ? attrIn.__customInputs__ : {},
1199
+ __customInputFields__
1200
+ };
1201
+ delete attributes.workflowsActionType;
1202
+ return { ...action, workflowsActionType: "INTERNAL", attributes };
1203
+ }
1173
1204
  function hasId(action) {
1174
1205
  return typeof action.id === "string" && action.id.length > 0;
1175
1206
  }
@@ -1185,7 +1216,7 @@ function getStringArray(value) {
1185
1216
  }
1186
1217
  return value;
1187
1218
  }
1188
- function validateActionChain(actions) {
1219
+ function validateActionChain(actions, existingIds) {
1189
1220
  const byId = new Map(actions.filter(hasId).map((action) => [action.id, action]));
1190
1221
  for (const action of actions) {
1191
1222
  const attr = isRecord(action.attributes) ? action.attributes : void 0;
@@ -1207,12 +1238,24 @@ function validateActionChain(actions) {
1207
1238
  if (!attr.startAfter) throw new Error(`Wait action "${action.name}" missing required 'startAfter' in attributes.`);
1208
1239
  break;
1209
1240
  case "internal_update_opportunity": {
1210
- if (!hasId(action)) {
1211
- throw new Error(
1212
- `Action "${action.name}" (internal_update_opportunity / "Update Opportunity" / move-to-stage) cannot be created via the API yet: GHL's workflow builder rejects a synthesized node with "action has a corrupted type", which silently fails the whole save. Build this one step in the GHL UI (Workflow > add action > Update Opportunity), or pass through an existing one read via get_workflow_full (it keeps its id). Every other action type in this call works.`
1213
- );
1241
+ const isRoundTripped = hasId(action) && (existingIds ? existingIds.has(action.id) : true);
1242
+ if (!isRoundTripped) {
1243
+ const cif = Array.isArray(attr.__customInputFields__) ? attr.__customInputFields__ : null;
1244
+ if (!cif) {
1245
+ throw new Error(
1246
+ `Internal update opportunity action "${action.name}" missing '__customInputFields__' array (needs a pipelineId entry and a pipelineStageId entry). Use get_pipelines to find the IDs.`
1247
+ );
1248
+ }
1249
+ const target = (ff) => cif.find((f) => f && f.filterField === ff);
1250
+ for (const ff of ["pipelineId", "pipelineStageId"]) {
1251
+ const t = target(ff);
1252
+ if (!t || !isIdShaped(t.value)) {
1253
+ throw new Error(
1254
+ `Internal update opportunity action "${action.name}" needs a valid ${ff} entry (an id, not a name) in '__customInputFields__'. Use get_pipelines / list_pipelines_full to find the IDs. (A missing or non-existent id makes GHL silently fail this action and can kill the rest.)`
1255
+ );
1256
+ }
1257
+ }
1214
1258
  }
1215
- if (!Array.isArray(attr.__customInputFields__)) throw new Error(`Internal update opportunity action "${action.name}" missing '__customInputFields__' array.`);
1216
1259
  break;
1217
1260
  }
1218
1261
  case "remove_from_workflow":
@@ -1686,7 +1729,8 @@ ${errorBody}`
1686
1729
  }
1687
1730
  const currentActions = current.workflowData?.templates || [];
1688
1731
  const newActions = updates.actions ?? currentActions;
1689
- const linkedActions = this.buildActionChain(newActions);
1732
+ const existingActionIds = new Set(currentActions.filter(hasId).map((a) => a.id));
1733
+ const linkedActions = this.buildActionChain(newActions, existingActionIds);
1690
1734
  const currentIds = new Set(currentActions.filter(hasId).map((a) => a.id));
1691
1735
  const newIds = new Set(linkedActions.filter(hasId).map((a) => a.id));
1692
1736
  const createdSteps = linkedActions.filter(hasId).filter((a) => !currentIds.has(a.id)).map((a) => a.id);
@@ -1831,9 +1875,9 @@ ${errorBody}`
1831
1875
  * saves correctly but subsequent actions get skipped during execution.
1832
1876
  * Only use arrays for branching nodes (if/else) that point to multiple targets.
1833
1877
  */
1834
- buildActionChain(actions) {
1835
- validateActionChain(actions);
1836
- const linked = actions.map(normalizeRemoveFromWorkflowAction).map((action, i) => {
1878
+ buildActionChain(actions, existingIds) {
1879
+ validateActionChain(actions, existingIds);
1880
+ const linked = actions.map(normalizeRemoveFromWorkflowAction).map(normalizeInternalUpdateOpportunityAction).map((action, i) => {
1837
1881
  const copy = { ...action };
1838
1882
  if (!copy.id) {
1839
1883
  copy.id = crypto.randomUUID();
@@ -5512,7 +5556,7 @@ function registerWorkflowBuilderTools(server2, client) {
5512
5556
  );
5513
5557
  server2.tool(
5514
5558
  "update_workflow_actions",
5515
- "Update a workflow's actions (steps), triggers, name, or status. IMPORTANT: Call get_workflow_full first to see the current state before updating. Handles version tracking automatically. Uses the internal builder API (requires Firebase auth). Action types: sms, email, add_contact_tag, remove_contact_tag, wait, webhook, internal_update_opportunity, custom_code, update_contact_field, add_notes, internal_notification, task_notification, remove_from_workflow, add_to_workflow, goto, transition, workflow_goal. NOTE: internal_update_opportunity (GHL's 'Update Opportunity' / move-to-stage action) cannot be created fresh through this API \u2014 GHL's builder rejects a synthesized node with 'action has a corrupted type' and fails the whole save; add that single step in the GHL UI, or pass through one already read via get_workflow_full (it keeps its node id). For if/else, call build_if_else_branch and include its returned nodes; if_else is a node discriminator, not a standalone action. For goal events (exit-on-condition nodes), call build_goal_event to get a correctly-shaped workflow_goal node \u2014 wire it in by setting the prior action's `next` to the goal node's id. Trigger types: all 57 native GHL trigger types have typed validation; any unknown trigger type passes through via a permissive fallback so reads never crash.",
5559
+ "Update a workflow's actions (steps), triggers, name, or status. IMPORTANT: Call get_workflow_full first to see the current state before updating. Handles version tracking automatically. Uses the internal builder API (requires Firebase auth). Action types: sms, email, add_contact_tag, remove_contact_tag, wait, webhook, internal_update_opportunity, custom_code, update_contact_field, add_notes, internal_notification, task_notification, remove_from_workflow, add_to_workflow, goto, transition, workflow_goal. NOTE: internal_update_opportunity (GHL's 'Create/Update Opportunity' / move-to-stage action) IS creatable from scratch \u2014 this tool normalizes it to GHL's exact node shape (workflowsActionType:'INTERNAL' hoisted to the NODE level; a nested copy is what makes GHL reject the node as 'action has a corrupted type'). A synthesized node needs both a pipelineId and a pipelineStageId entry in attributes.__customInputFields__ (use get_pipelines for the IDs); a node round-tripped via get_workflow_full keeps its id and passes through unchanged. For if/else, call build_if_else_branch and include its returned nodes; if_else is a node discriminator, not a standalone action. For goal events (exit-on-condition nodes), call build_goal_event to get a correctly-shaped workflow_goal node \u2014 wire it in by setting the prior action's `next` to the goal node's id. Trigger types: all 57 native GHL trigger types have typed validation; any unknown trigger type passes through via a permissive fallback so reads never crash.",
5516
5560
  {
5517
5561
  workflowId: import_zod33.z.string().describe("The workflow ID to update."),
5518
5562
  name: import_zod33.z.string().optional().describe("New workflow name."),
@@ -8667,10 +8711,6 @@ ${errors.join("\n")}` : "\nNo errors!",
8667
8711
  // src/tools/validators.ts
8668
8712
  var import_zod47 = require("zod");
8669
8713
  var ALL_CATEGORIES = ["pipeline", "stage", "custom_field", "user", "workflow", "form", "calendar", "survey"];
8670
- var ID_SHAPE = /^([A-Za-z0-9]{17,}|[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12})$/;
8671
- function isIdShaped(s) {
8672
- return typeof s === "string" && ID_SHAPE.test(s);
8673
- }
8674
8714
  var STANDARD_CONTACT_FIELDS = /* @__PURE__ */ new Set([
8675
8715
  "first_name",
8676
8716
  "firstname",
@@ -10921,6 +10961,18 @@ function validateBuildPlan(input) {
10921
10961
  seen.add(key);
10922
10962
  }
10923
10963
  }
10964
+ for (const fn of plan.funnels ?? []) {
10965
+ const seen = /* @__PURE__ */ new Set();
10966
+ for (const pg of fn.pages) {
10967
+ const key = pg.name.trim().toLowerCase();
10968
+ if (seen.has(key)) {
10969
+ allErrors.push(
10970
+ `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`
10971
+ );
10972
+ }
10973
+ seen.add(key);
10974
+ }
10975
+ }
10924
10976
  const nameGroups = [
10925
10977
  ["pipelines", (plan.pipelines ?? []).map((x) => ({ ref: x.ref, name: x.name }))],
10926
10978
  ["customFields", (plan.customFields ?? []).map((x) => ({ ref: x.ref, name: x.name }))],
@@ -11437,6 +11489,10 @@ function renderReport(plan, result, ctx) {
11437
11489
  any = true;
11438
11490
  L.push(` \u2022 [calendar] ${c.reason}`);
11439
11491
  }
11492
+ for (const fn of plan.funnels ?? []) {
11493
+ any = true;
11494
+ L.push(` \u2022 [funnel] Design + populate the pages of funnel "${fn.name}" (Blueprint builds the funnel + steps; page content/design is manual).`);
11495
+ }
11440
11496
  for (const w of result.workflows) {
11441
11497
  for (const m of w.manual) {
11442
11498
  any = true;
@@ -11459,6 +11515,9 @@ function renderReport(plan, result, ctx) {
11459
11515
  // src/intake-to-build/execute.ts
11460
11516
  var norm2 = (s) => s.trim().toLowerCase();
11461
11517
  var sleep = (ms) => new Promise((r) => setTimeout(r, ms));
11518
+ function slugifyName(s) {
11519
+ return s.toLowerCase().split(/[^a-z0-9]+/).filter(Boolean).join("-");
11520
+ }
11462
11521
  async function executeBackbone(plan, deps, opts = {}) {
11463
11522
  const retries = opts.verifyRetries ?? 4;
11464
11523
  const backoff = opts.verifyBackoffMs ?? 500;
@@ -11667,6 +11726,85 @@ async function executeBackbone(plan, deps, opts = {}) {
11667
11726
  idMap[form.ref] = formId;
11668
11727
  built.push({ ref: form.ref, type: "form", name: form.name, status: "created", realId: formId });
11669
11728
  }
11729
+ const formNameByRef = new Map((plan.forms ?? []).map((f) => [f.ref, f.name]));
11730
+ const calNameByRef = new Map((plan.calendars ?? []).map((c) => [c.ref, c.name]));
11731
+ const funnelContentReason = (fn) => {
11732
+ const pageBits = fn.pages.map((pg) => {
11733
+ const hosts = [];
11734
+ if (pg.formRef) hosts.push(`hosts form "${formNameByRef.get(pg.formRef) ?? pg.formRef}"`);
11735
+ if (pg.calendarRef) hosts.push(`links calendar "${calNameByRef.get(pg.calendarRef) ?? pg.calendarRef}"`);
11736
+ return `page "${pg.name}"${hosts.length ? ` (${hosts.join(", ")})` : ""}`;
11737
+ });
11738
+ return `Design + publish the pages of funnel "${fn.name}": ${pageBits.join("; ")}. (Blueprint built the funnel + steps; page content/design is manual.)`;
11739
+ };
11740
+ for (const fn of plan.funnels ?? []) {
11741
+ let funnels;
11742
+ try {
11743
+ funnels = await deps.listFunnels();
11744
+ } catch (e) {
11745
+ return halt(fn.ref, "funnel", `could not read existing funnels: ${msg(e)}`);
11746
+ }
11747
+ const matches = funnels.filter((f) => norm2(f.name) === norm2(fn.name));
11748
+ if (matches.length > 1) {
11749
+ 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.`);
11750
+ }
11751
+ if (matches.length === 1) {
11752
+ const ex = matches[0];
11753
+ const pageBindings = [];
11754
+ const problems = [];
11755
+ for (const pg of fn.pages) {
11756
+ const stepMatches = ex.steps.filter((s) => norm2(s.name) === norm2(pg.name));
11757
+ if (stepMatches.length === 1) pageBindings.push({ ref: pg.ref, name: pg.name, id: stepMatches[0].id });
11758
+ else if (stepMatches.length === 0) problems.push(`page "${pg.name}" has no matching step`);
11759
+ else problems.push(`page "${pg.name}" matches ${stepMatches.length} steps`);
11760
+ }
11761
+ if (problems.length) {
11762
+ 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.)`);
11763
+ }
11764
+ idMap[fn.ref] = ex.id;
11765
+ built.push({ ref: fn.ref, type: "funnel", name: fn.name, status: "existing", realId: ex.id });
11766
+ for (const b of pageBindings) {
11767
+ idMap[b.ref] = b.id;
11768
+ built.push({ ref: b.ref, type: "page", name: b.name, status: "existing", realId: b.id });
11769
+ }
11770
+ manual.push({ ref: fn.ref, type: "funnel-page-content", name: fn.name, reason: funnelContentReason(fn) });
11771
+ continue;
11772
+ }
11773
+ let funnelId;
11774
+ try {
11775
+ funnelId = await deps.createFunnel(fn.name);
11776
+ } catch (e) {
11777
+ return halt(fn.ref, "funnel", `create failed: ${msg(e)}`);
11778
+ }
11779
+ if (!funnelId) return halt(fn.ref, "funnel", "funnel create returned no id");
11780
+ const stepBindings = [];
11781
+ try {
11782
+ for (const pg of fn.pages) {
11783
+ const stepId = await deps.createFunnelStep(funnelId, pg.name, `/${slugifyName(pg.name)}`);
11784
+ if (!stepId) throw new Error(`step "${pg.name}" returned no id`);
11785
+ stepBindings.push({ ref: pg.ref, name: pg.name, id: stepId });
11786
+ }
11787
+ } catch (e) {
11788
+ let rolledBack = false;
11789
+ try {
11790
+ await deps.deleteFunnel(funnelId);
11791
+ rolledBack = true;
11792
+ } catch {
11793
+ }
11794
+ return halt(
11795
+ fn.ref,
11796
+ "funnel",
11797
+ 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)}`
11798
+ );
11799
+ }
11800
+ idMap[fn.ref] = funnelId;
11801
+ built.push({ ref: fn.ref, type: "funnel", name: fn.name, status: "created", realId: funnelId });
11802
+ for (const sb of stepBindings) {
11803
+ idMap[sb.ref] = sb.id;
11804
+ built.push({ ref: sb.ref, type: "page", name: sb.name, status: "created", realId: sb.id });
11805
+ }
11806
+ manual.push({ ref: fn.ref, type: "funnel-page-content", name: fn.name, reason: funnelContentReason(fn) });
11807
+ }
11670
11808
  return { ok: true, idMap, built, manual, deferred: deferredSections(plan) };
11671
11809
  }
11672
11810
  async function buildSimple(objects, type, list, create, nameOf, pollForNew, idMap, built) {
@@ -11703,7 +11841,6 @@ async function buildSimple(objects, type, list, create, nameOf, pollForNew, idMa
11703
11841
  }
11704
11842
  function deferredSections(plan) {
11705
11843
  const out = [];
11706
- if (plan.funnels?.length) out.push({ section: "funnels", count: plan.funnels.length });
11707
11844
  if (plan.workflows?.length) out.push({ section: "workflows", count: plan.workflows.length });
11708
11845
  return out;
11709
11846
  }
@@ -11840,6 +11977,38 @@ function pickPipelines(raw) {
11840
11977
  }
11841
11978
  return out;
11842
11979
  }
11980
+ function pickFunnels(raw) {
11981
+ const root = raw && typeof raw === "object" ? raw : {};
11982
+ const list = Array.isArray(root.funnels) ? root.funnels : Array.isArray(raw) ? raw : [];
11983
+ const out = [];
11984
+ for (const item of list) {
11985
+ if (!item || typeof item !== "object") continue;
11986
+ const f = item;
11987
+ const id = typeof f._id === "string" ? f._id : typeof f.id === "string" ? f.id : void 0;
11988
+ const name = typeof f.name === "string" ? f.name : void 0;
11989
+ if (!id || !name) continue;
11990
+ const stepsRaw = Array.isArray(f.steps) ? f.steps : [];
11991
+ const steps = stepsRaw.filter((s) => !!s && typeof s === "object").map((s) => ({
11992
+ id: typeof s._id === "string" ? s._id : typeof s.id === "string" ? s.id : "",
11993
+ name: typeof s.name === "string" ? s.name : ""
11994
+ })).filter((s) => s.id && s.name);
11995
+ out.push({ id, name, steps });
11996
+ }
11997
+ return out;
11998
+ }
11999
+ function extractFunnelId(result) {
12000
+ if (!result || typeof result !== "object") return void 0;
12001
+ const r = result;
12002
+ if (typeof r._id === "string") return r._id;
12003
+ if (typeof r.id === "string") return r.id;
12004
+ const funnel = r.funnel;
12005
+ if (funnel && typeof funnel === "object") {
12006
+ const f = funnel;
12007
+ if (typeof f._id === "string") return f._id;
12008
+ if (typeof f.id === "string") return f.id;
12009
+ }
12010
+ return void 0;
12011
+ }
11843
12012
  function makeExecuteDeps(client, builderClient, locationId2) {
11844
12013
  const pipelineApi = async (method, path7, body) => {
11845
12014
  const headers = await builderClient.buildHeaders();
@@ -11850,6 +12019,27 @@ function makeExecuteDeps(client, builderClient, locationId2) {
11850
12019
  if (!response.ok) {
11851
12020
  const text2 = await response.text();
11852
12021
  throw new Error(`Pipeline API ${response.status}: ${method} ${path7}
12022
+ ${text2.slice(0, 300)}`);
12023
+ }
12024
+ const text = await response.text();
12025
+ if (!text) return {};
12026
+ try {
12027
+ return JSON.parse(text);
12028
+ } catch {
12029
+ return JSON.parse(text.replace(/[\x00-\x1F\x7F]/g, ""));
12030
+ }
12031
+ };
12032
+ const funnelApi = async (method, path7, body) => {
12033
+ const headers = await builderClient.buildHeaders();
12034
+ headers.Origin = "https://app.gohighlevel.com";
12035
+ headers.Referer = "https://app.gohighlevel.com/";
12036
+ const url = `https://backend.leadconnectorhq.com/funnels${path7}`;
12037
+ const options = { method, headers };
12038
+ if (body && (method === "POST" || method === "PUT")) options.body = JSON.stringify(body);
12039
+ const response = await fetch(url, options);
12040
+ if (!response.ok) {
12041
+ const text2 = await response.text();
12042
+ throw new Error(`Funnel API ${response.status}: ${method} ${path7}
11853
12043
  ${text2.slice(0, 300)}`);
11854
12044
  }
11855
12045
  const text = await response.text();
@@ -11959,6 +12149,49 @@ ${text2.slice(0, 300)}`);
11959
12149
  }
11960
12150
  throw err;
11961
12151
  }
12152
+ },
12153
+ // ── Funnels ────────────────────────────────────────────────────────────
12154
+ // Internal funnel API. Origin/Referer are REQUIRED on writes (else 401 "Error
12155
+ // calling IAM service") — mirrors funnel-builder.ts.
12156
+ // Paginate so never-clobber sees ALL funnels (a single page would miss a
12157
+ // same-named funnel beyond it → duplicate). Dedup by id and stop when a page
12158
+ // adds nothing new — robust whether or not the endpoint honors `offset`.
12159
+ listFunnels: async () => {
12160
+ const byId = /* @__PURE__ */ new Map();
12161
+ const pageSize = 100;
12162
+ const maxFunnels = 5e3;
12163
+ for (let offset = 0; offset < maxFunnels; offset += pageSize) {
12164
+ const page = pickFunnels(await funnelApi("GET", `/funnel/list?locationId=${locationId2}&limit=${pageSize}&offset=${offset}&getStats=true`));
12165
+ let added = 0;
12166
+ for (const f of page) if (!byId.has(f.id)) {
12167
+ byId.set(f.id, f);
12168
+ added++;
12169
+ }
12170
+ if (page.length < pageSize || added === 0) break;
12171
+ }
12172
+ return [...byId.values()];
12173
+ },
12174
+ createFunnel: async (name) => {
12175
+ const result = await funnelApi("POST", `/funnel/create?locationId=${locationId2}`, {
12176
+ name,
12177
+ locationId: locationId2,
12178
+ type: "funnel",
12179
+ steps: []
12180
+ });
12181
+ const id = extractFunnelId(result);
12182
+ if (!id) throw new Error(`create_funnel returned no id: ${JSON.stringify(result).slice(0, 200)}`);
12183
+ return id;
12184
+ },
12185
+ createFunnelStep: async (funnelId, name, url) => {
12186
+ const stepId = crypto.randomUUID();
12187
+ await funnelApi("POST", `/funnel/create-step`, {
12188
+ funnelId,
12189
+ step: { id: stepId, name, url, pages: [], control_traffic: 100, split: false, type: "optin_funnel_page" }
12190
+ });
12191
+ return stepId;
12192
+ },
12193
+ deleteFunnel: async (funnelId) => {
12194
+ await funnelApi("POST", `/funnel/delete`, { funnelId, locationId: locationId2, userId: builderClient.getUserId() });
11962
12195
  }
11963
12196
  };
11964
12197
  }
@@ -12017,7 +12250,7 @@ function registerIntakeToBuildTools(server2, client, builderClient) {
12017
12250
  );
12018
12251
  server2.tool(
12019
12252
  "apply_build_plan",
12020
- `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/workflows are surfaced as manual next steps, not auto-built yet. Always confirms the active location and validates the plan before any write.`,
12253
+ `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.`,
12021
12254
  {
12022
12255
  plan: import_zod53.z.record(import_zod53.z.unknown()).describe("The approved \xA75 Build Plan object."),
12023
12256
  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)."),
@@ -12092,7 +12325,7 @@ function registerIntakeToBuildTools(server2, client, builderClient) {
12092
12325
  }
12093
12326
  const deps = makeExecuteDeps(client, builderClient, activeLocation);
12094
12327
  const exec = await executeBackbone(typedPlan, deps);
12095
- const calendarManualLines = exec.manual.map((m) => `[calendar] ${m.reason}`);
12328
+ const execManualLines = exec.manual.map((m) => `[${m.type}] ${m.reason}`);
12096
12329
  const manualLines = result.workflows.flatMap((w) => [
12097
12330
  ...w.manual.map((m) => `[${w.name}] ${m.reason}`),
12098
12331
  ...w.needsContent.map((c) => `[${w.name}] ${c.reason}`)
@@ -12110,9 +12343,9 @@ function registerIntakeToBuildTools(server2, client, builderClient) {
12110
12343
  manual: exec.manual,
12111
12344
  idMap: exec.idMap,
12112
12345
  deferred: exec.deferred,
12113
- deferredNote: "execute builds the CRM backbone (pipelines, custom fields, tags, custom values), calendars, and forms live. Staff-requiring calendars (round_robin etc.) auto-build only when you are the sole user; otherwise they're listed under manual steps. Funnels/workflows are planned but NOT auto-built yet \u2014 create them via the GHL UI or the dedicated tools, in this order: funnels \u2192 workflows.",
12114
- nextManualSteps: [...calendarManualLines, ...manualLines, ...handoffLines],
12115
- 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.`
12346
+ 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.",
12347
+ nextManualSteps: [...execManualLines, ...manualLines, ...handoffLines],
12348
+ 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.`
12116
12349
  });
12117
12350
  }
12118
12351
  const collisions = result.items.filter((i) => i.status === "existing");
@@ -12150,7 +12383,7 @@ function registerIntakeToBuildTools(server2, client, builderClient) {
12150
12383
  calendarsManual: result.calendarsManual,
12151
12384
  handoffs: result.handoffs,
12152
12385
  report,
12153
- 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.'
12386
+ 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.'
12154
12387
  });
12155
12388
  } catch (error) {
12156
12389
  return errorResponse(error);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@elitedcs/ghl-mcp",
3
- "version": "3.39.0",
3
+ "version": "3.41.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",
@@ -165,7 +165,7 @@
165
165
  "workflowsActionType": "INTERNAL",
166
166
  "type": "internal_update_opportunity"
167
167
  },
168
- "notes": "CANNOT CREATE FRESH VIA THIS API: GHL's builder rejects a synthesized internal_update_opportunity node with 'action has a corrupted type' and fails the whole save. Build this step in the GHL UI, or round-trip one read via get_workflow_full (it keeps its node id). The shape below is correct for READING / round-tripping. Use pipeline and stage IDs (not names). Use get_pipelines or list_pipelines_full to find IDs FIRST. CRITICAL: If the pipelineId or stageId don't exist in the target sub-account, GHL silently fails this action AND can kill subsequent actions in the workflow. Always verify IDs exist before deploying."
168
+ "notes": "CREATABLE from scratch (re-enabled v3.41.0). The discriminator workflowsActionType:'INTERNAL' MUST sit at the NODE level, never nested in attributes — a nested copy makes GHL reject the node as 'action has a corrupted type' and silently fail the whole save. update_workflow_actions normalizes this for you (hoists workflowsActionType to the node level, scaffolds allowBackward + __customInputs__, gives each __customInputFields__ entry an __customInputs__). The shape below (workflowsActionType at the node level alongside type/name/attributes) is correct for both creating and round-tripping. Use pipeline and stage IDs (not names) get_pipelines / list_pipelines_full to find them FIRST. CRITICAL: if the pipelineId or pipelineStageId don't exist in the target sub-account, GHL silently fails this action AND can kill subsequent actions. A synthesized node needs BOTH a pipelineId and a pipelineStageId entry; a node round-tripped via get_workflow_full keeps its id and passes through unchanged."
169
169
  },
170
170
 
171
171
  "_if_else_branching": {