@elitedcs/ghl-mcp 3.40.0 → 3.42.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.40.0",
34
+ version: "3.42.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",
@@ -11049,7 +11089,9 @@ function buildRefIndex(plan) {
11049
11089
  sms: /* @__PURE__ */ new Map(),
11050
11090
  workflowName: /* @__PURE__ */ new Map(),
11051
11091
  pipelineName: /* @__PURE__ */ new Map(),
11052
- stageName: /* @__PURE__ */ new Map()
11092
+ stageName: /* @__PURE__ */ new Map(),
11093
+ formName: /* @__PURE__ */ new Map(),
11094
+ calendarName: /* @__PURE__ */ new Map()
11053
11095
  };
11054
11096
  for (const t of plan.tags ?? []) idx.tagName.set(t.ref, t.name);
11055
11097
  for (const f of plan.customFields ?? []) {
@@ -11063,6 +11105,8 @@ function buildRefIndex(plan) {
11063
11105
  idx.pipelineName.set(p.ref, p.name);
11064
11106
  for (const st of p.stages) idx.stageName.set(st.ref, st.name);
11065
11107
  }
11108
+ for (const fm of plan.forms ?? []) idx.formName.set(fm.ref, fm.name);
11109
+ for (const c of plan.calendars ?? []) idx.calendarName.set(c.ref, c.name);
11066
11110
  return idx;
11067
11111
  }
11068
11112
  function htmlWrap(text) {
@@ -11244,13 +11288,31 @@ function expandAction(action, idx, idMap) {
11244
11288
  }
11245
11289
  case "create_opportunity":
11246
11290
  case "update_opportunity": {
11291
+ const pipelineId = resolveId(action.pipelineRef, idMap);
11292
+ const stageId = resolveId(action.stageRef, idMap);
11293
+ const pendingRefs = [];
11294
+ if (isPending(pipelineId)) pendingRefs.push(action.pipelineRef);
11295
+ if (isPending(stageId)) pendingRefs.push(action.stageRef);
11247
11296
  const pName = idx.pipelineName.get(action.pipelineRef) ?? action.pipelineRef;
11248
11297
  const sName = idx.stageName.get(action.stageRef) ?? action.stageRef;
11249
- const verb = action.type === "create_opportunity" ? "Create" : "Move";
11298
+ const verb = action.type === "create_opportunity" ? "Create opportunity" : "Move opportunity";
11250
11299
  return {
11251
- kind: "manual",
11252
- logicalType: action.type,
11253
- reason: `${verb} opportunity \u2192 "${pName}" / stage "${sName}": GHL rejects a synthesized opportunity node ("corrupted type") and silently kills the whole workflow save. Add this step by hand in the GHL workflow builder (Add action \u2192 Create/Update Opportunity), or round-trip an existing node.`
11300
+ kind: "expanded",
11301
+ pendingRefs,
11302
+ native: {
11303
+ type: "internal_update_opportunity",
11304
+ name: `${verb}: ${pName} / ${sName}`,
11305
+ // NODE-level discriminator (the normalizer also forces this; included so
11306
+ // the expanded action is correct even outside the save path).
11307
+ workflowsActionType: "INTERNAL",
11308
+ attributes: {
11309
+ type: "internal_update_opportunity",
11310
+ __customInputFields__: [
11311
+ { filterField: "pipelineId", value: pipelineId, valueFieldType: "select", dataType: "SINGLE_OPTIONS" },
11312
+ { filterField: "pipelineStageId", value: stageId, valueFieldType: "select", dataType: "SINGLE_OPTIONS" }
11313
+ ]
11314
+ }
11315
+ }
11254
11316
  };
11255
11317
  }
11256
11318
  case "goal_event": {
@@ -11276,6 +11338,42 @@ function expandAction(action, idx, idMap) {
11276
11338
  }
11277
11339
  }
11278
11340
  var MAX_ACTIONS_PER_WORKFLOW = 40;
11341
+ function triggerPlainEnglish(trigger, idx) {
11342
+ const formName = trigger.formRef ? idx.formName.get(trigger.formRef) ?? trigger.formRef : "?";
11343
+ const stageName = trigger.stageRef ? idx.stageName.get(trigger.stageRef) ?? trigger.stageRef : "?";
11344
+ const calName = trigger.calendarRef ? idx.calendarName.get(trigger.calendarRef) ?? trigger.calendarRef : "?";
11345
+ switch (trigger.type) {
11346
+ case "form_submission":
11347
+ case "form_submitted":
11348
+ return `when form "${formName}" is submitted`;
11349
+ case "pipeline_stage_updated":
11350
+ return `when an opportunity reaches stage "${stageName}"`;
11351
+ case "appointment":
11352
+ return `on an appointment event${trigger.calendarRef ? ` for calendar "${calName}"` : ""}`;
11353
+ case "customer_reply":
11354
+ return "when a contact replies";
11355
+ default:
11356
+ return `trigger type "${trigger.type}"`;
11357
+ }
11358
+ }
11359
+ function expandTrigger(trigger, idx) {
11360
+ if (trigger.type === "contact_tag") {
11361
+ if (!trigger.tagRef) return { kind: "manual", reason: "Set this workflow's contact-tag trigger in the GHL UI (the plan trigger has no tag)." };
11362
+ const tagName = idx.tagName.get(trigger.tagRef) ?? trigger.tagRef;
11363
+ return {
11364
+ kind: "native",
11365
+ trigger: {
11366
+ name: "Contact Tag",
11367
+ type: "contact_tag",
11368
+ conditions: [{ operator: "index-of-true", field: "tagsAdded", value: tagName, title: "Tag Added", type: "select", id: "tag-added" }]
11369
+ }
11370
+ };
11371
+ }
11372
+ return {
11373
+ kind: "manual",
11374
+ reason: `Set this workflow's trigger in the GHL UI: ${triggerPlainEnglish(trigger, idx)}, then publish. (Blueprint builds the workflow + all its steps as DRAFT; only this trigger type isn't auto-built yet.)`
11375
+ };
11376
+ }
11279
11377
  function expandWorkflow(workflow, idx, idMap, gatedBy) {
11280
11378
  const nativeActions = [];
11281
11379
  const manual = [];
@@ -11293,6 +11391,13 @@ function expandWorkflow(workflow, idx, idMap, gatedBy) {
11293
11391
  }
11294
11392
  });
11295
11393
  const splitInto = Math.max(1, Math.ceil(nativeActions.length / MAX_ACTIONS_PER_WORKFLOW));
11394
+ let nativeTrigger;
11395
+ let triggerManual;
11396
+ if (workflow.trigger) {
11397
+ const t = expandTrigger(workflow.trigger, idx);
11398
+ if (t.kind === "native") nativeTrigger = t.trigger;
11399
+ else triggerManual = t.reason;
11400
+ }
11296
11401
  return {
11297
11402
  ref: workflow.ref,
11298
11403
  name: workflow.name,
@@ -11301,7 +11406,9 @@ function expandWorkflow(workflow, idx, idMap, gatedBy) {
11301
11406
  needsContent,
11302
11407
  pendingRefs: [...pendingRefs],
11303
11408
  splitInto,
11304
- gatedBy
11409
+ gatedBy,
11410
+ nativeTrigger,
11411
+ triggerManual
11305
11412
  };
11306
11413
  }
11307
11414
  function scanSection(section2, planObjects, existing) {
@@ -11454,6 +11561,10 @@ function renderReport(plan, result, ctx) {
11454
11561
  L.push(` \u2022 [funnel] Design + populate the pages of funnel "${fn.name}" (Blueprint builds the funnel + steps; page content/design is manual).`);
11455
11562
  }
11456
11563
  for (const w of result.workflows) {
11564
+ if (w.triggerManual) {
11565
+ any = true;
11566
+ L.push(` \u2022 [${w.name}] ${w.triggerManual}`);
11567
+ }
11457
11568
  for (const m of w.manual) {
11458
11569
  any = true;
11459
11570
  L.push(` \u2022 [${w.name}] ${m.reason}`);
@@ -11765,6 +11876,73 @@ async function executeBackbone(plan, deps, opts = {}) {
11765
11876
  }
11766
11877
  manual.push({ ref: fn.ref, type: "funnel-page-content", name: fn.name, reason: funnelContentReason(fn) });
11767
11878
  }
11879
+ const wfIndex = buildRefIndex(plan);
11880
+ const wfIdMap = new Map(Object.entries(idMap));
11881
+ for (const wf of plan.workflows ?? []) {
11882
+ let workflows;
11883
+ try {
11884
+ workflows = await deps.listWorkflows();
11885
+ } catch (e) {
11886
+ return halt(wf.ref, "workflow", `could not read existing workflows: ${msg(e)}`);
11887
+ }
11888
+ const matches = workflows.filter((w) => norm2(w.name) === norm2(wf.name));
11889
+ if (matches.length > 1) {
11890
+ return halt(wf.ref, "workflow", `${matches.length} existing workflows are named "${wf.name}" \u2014 ambiguous, cannot safely bind ${wf.ref}. Resolve the duplicate in GHL, then re-run.`);
11891
+ }
11892
+ const expansion = expandWorkflow(wf, wfIndex, wfIdMap, []);
11893
+ if (expansion.pendingRefs.length) {
11894
+ return halt(wf.ref, "workflow", `workflow "${wf.name}" references objects that weren't built/resolved: ${expansion.pendingRefs.join(", ")}. Cannot build it.`);
11895
+ }
11896
+ if (matches.length === 1) {
11897
+ const exId = matches[0].id;
11898
+ if (expansion.nativeActions.length > 0) {
11899
+ let existingCount = -1;
11900
+ try {
11901
+ existingCount = await deps.getWorkflowActionCount(exId);
11902
+ } catch {
11903
+ existingCount = -1;
11904
+ }
11905
+ if (existingCount === 0) {
11906
+ return halt(wf.ref, "workflow", `a workflow named "${wf.name}" already exists but has NO actions \u2014 likely an empty orphan from a failed prior run (or a name collision). Delete it in GHL, then re-run. (Blueprint never modifies an existing workflow.)`);
11907
+ }
11908
+ }
11909
+ idMap[wf.ref] = exId;
11910
+ built.push({ ref: wf.ref, type: "workflow", name: wf.name, status: "existing", realId: exId });
11911
+ continue;
11912
+ }
11913
+ let wfId;
11914
+ try {
11915
+ wfId = await deps.createWorkflow(wf.name);
11916
+ } catch (e) {
11917
+ return halt(wf.ref, "workflow", `create failed: ${msg(e)}`);
11918
+ }
11919
+ if (!wfId) return halt(wf.ref, "workflow", "workflow create returned no id");
11920
+ const triggers = expansion.nativeTrigger ? [expansion.nativeTrigger] : [];
11921
+ try {
11922
+ await deps.saveWorkflow(wfId, { actions: expansion.nativeActions, triggers, stopOnResponse: wf.stopOnResponse });
11923
+ if (expansion.nativeActions.length > 0) {
11924
+ const count = await deps.getWorkflowActionCount(wfId);
11925
+ if (count === 0) throw new Error("saved but no actions persisted (read-after-write)");
11926
+ }
11927
+ } catch (e) {
11928
+ let rolledBack = false;
11929
+ try {
11930
+ await deps.deleteWorkflow(wfId);
11931
+ rolledBack = true;
11932
+ } catch {
11933
+ }
11934
+ return halt(
11935
+ wf.ref,
11936
+ "workflow",
11937
+ rolledBack ? `actions/trigger save failed; the partial workflow was rolled back (deleted): ${msg(e)}` : `actions/trigger save failed AND the rollback delete also failed \u2014 manually delete the orphan workflow (id ${wfId}) in GHL before re-running: ${msg(e)}`
11938
+ );
11939
+ }
11940
+ idMap[wf.ref] = wfId;
11941
+ built.push({ ref: wf.ref, type: "workflow", name: wf.name, status: "created", realId: wfId });
11942
+ if (expansion.triggerManual) manual.push({ ref: wf.ref, type: "workflow-trigger", name: wf.name, reason: `[${wf.name}] ${expansion.triggerManual}` });
11943
+ for (const nc of expansion.needsContent) manual.push({ ref: wf.ref, type: "workflow-action", name: wf.name, reason: `[${wf.name}] ${nc.reason}` });
11944
+ for (const m of expansion.manual) manual.push({ ref: wf.ref, type: "workflow-action", name: wf.name, reason: `[${wf.name}] ${m.reason}` });
11945
+ }
11768
11946
  return { ok: true, idMap, built, manual, deferred: deferredSections(plan) };
11769
11947
  }
11770
11948
  async function buildSimple(objects, type, list, create, nameOf, pollForNew, idMap, built) {
@@ -11799,10 +11977,8 @@ async function buildSimple(objects, type, list, create, nameOf, pollForNew, idMa
11799
11977
  }
11800
11978
  return null;
11801
11979
  }
11802
- function deferredSections(plan) {
11803
- const out = [];
11804
- if (plan.workflows?.length) out.push({ section: "workflows", count: plan.workflows.length });
11805
- return out;
11980
+ function deferredSections(_plan) {
11981
+ return [];
11806
11982
  }
11807
11983
  function msg(e) {
11808
11984
  return e instanceof Error ? e.message : String(e);
@@ -12152,6 +12328,35 @@ ${text2.slice(0, 300)}`);
12152
12328
  },
12153
12329
  deleteFunnel: async (funnelId) => {
12154
12330
  await funnelApi("POST", `/funnel/delete`, { funnelId, locationId: locationId2, userId: builderClient.getUserId() });
12331
+ },
12332
+ // ── Workflows ───────────────────────────────────────────────────────────
12333
+ // NOTE: unlike /forms/ and /funnels/, the GHL /workflows/ list endpoint does
12334
+ // NOT paginate — it 422s on `limit`/`skip` ("property X should not exist") and
12335
+ // returns ALL workflows in one call (live-verified 2026-06-16). So a single
12336
+ // call IS the full never-clobber scan here; no pagination is possible or needed.
12337
+ listWorkflows: async () => pickObjects(await client.get("/workflows/", { params: { locationId: locationId2 } }), ["workflows"]),
12338
+ createWorkflow: async (name) => {
12339
+ const wf = await builderClient.createWorkflow(name);
12340
+ const id = wf && typeof wf === "object" ? wf : {};
12341
+ const wfId = typeof id.id === "string" ? id.id : typeof id._id === "string" ? id._id : void 0;
12342
+ if (!wfId) throw new Error(`create_workflow returned no id: ${JSON.stringify(wf).slice(0, 200)}`);
12343
+ return wfId;
12344
+ },
12345
+ saveWorkflow: async (workflowId, opts) => {
12346
+ await builderClient.updateWorkflow(workflowId, {
12347
+ actions: opts.actions,
12348
+ triggers: opts.triggers,
12349
+ status: "draft",
12350
+ stopOnResponse: opts.stopOnResponse
12351
+ });
12352
+ },
12353
+ getWorkflowActionCount: async (workflowId) => {
12354
+ const full = await builderClient.getWorkflow(workflowId);
12355
+ const templates = full && typeof full === "object" ? full.workflowData : void 0;
12356
+ return Array.isArray(templates?.templates) ? templates.templates.length : 0;
12357
+ },
12358
+ deleteWorkflow: async (workflowId) => {
12359
+ await builderClient.deleteWorkflow(workflowId);
12155
12360
  }
12156
12361
  };
12157
12362
  }
@@ -12210,7 +12415,7 @@ function registerIntakeToBuildTools(server2, client, builderClient) {
12210
12415
  );
12211
12416
  server2.tool(
12212
12417
  "apply_build_plan",
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.`,
12418
+ `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; page content/HTML is a manual step \u2014 plans carry outlines). Workflows build as DRAFT with all their logical actions expanded to native GHL JSON (incl. opportunity create/move steps, re-enabled v3.41.0) and chained; a contact_tag trigger is built automatically, other trigger types are surfaced as a manual step; the operator reviews + publishes. Always confirms the active location and validates the plan before any write.`,
12214
12419
  {
12215
12420
  plan: import_zod53.z.record(import_zod53.z.unknown()).describe("The approved \xA75 Build Plan object."),
12216
12421
  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)."),
@@ -12303,7 +12508,7 @@ function registerIntakeToBuildTools(server2, client, builderClient) {
12303
12508
  manual: exec.manual,
12304
12509
  idMap: exec.idMap,
12305
12510
  deferred: exec.deferred,
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.",
12511
+ deferredNote: "execute builds the WHOLE plan live: CRM backbone (pipelines, custom fields, tags, custom values), calendars, forms, funnels (funnel + named steps), and workflows (DRAFT, with all steps incl. opportunity create/move). Remaining manual steps are surfaced per item: staff-requiring calendars in multi-user accounts, funnel page content/design, workflow triggers other than contact_tag (set by hand), and publishing the DRAFT workflows.",
12307
12512
  nextManualSteps: [...execManualLines, ...manualLines, ...handoffLines],
12308
12513
  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.`
12309
12514
  });
@@ -12335,6 +12540,8 @@ function registerIntakeToBuildTools(server2, client, builderClient) {
12335
12540
  autoActions: w.nativeActions.length,
12336
12541
  manualSteps: w.manual,
12337
12542
  needsContent: w.needsContent,
12543
+ triggerManual: w.triggerManual,
12544
+ triggerAutoBuilt: !!w.nativeTrigger,
12338
12545
  pendingRefs: w.pendingRefs,
12339
12546
  splitInto: w.splitInto,
12340
12547
  gatedBy: w.gatedBy,
@@ -12343,7 +12550,7 @@ function registerIntakeToBuildTools(server2, client, builderClient) {
12343
12550
  calendarsManual: result.calendarsManual,
12344
12551
  handoffs: result.handoffs,
12345
12552
  report,
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.'
12553
+ next: 'Review the report. When it looks right, re-run with mode:"execute" to build it all live: CRM backbone (pipelines, fields, tags, custom values), calendars, forms, funnels (funnel + named steps), and workflows (DRAFT, with all steps incl. opportunity moves). Manual next steps: funnel page content/design, contact-tag-triggers build automatically but other trigger types are set by hand, then publish the workflows.'
12347
12554
  });
12348
12555
  } catch (error) {
12349
12556
  return errorResponse(error);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@elitedcs/ghl-mcp",
3
- "version": "3.40.0",
3
+ "version": "3.42.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": {