@elitedcs/ghl-mcp 3.41.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.
Files changed (2) hide show
  1. package/dist/index.js +181 -14
  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.41.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",
@@ -11089,7 +11089,9 @@ function buildRefIndex(plan) {
11089
11089
  sms: /* @__PURE__ */ new Map(),
11090
11090
  workflowName: /* @__PURE__ */ new Map(),
11091
11091
  pipelineName: /* @__PURE__ */ new Map(),
11092
- stageName: /* @__PURE__ */ new Map()
11092
+ stageName: /* @__PURE__ */ new Map(),
11093
+ formName: /* @__PURE__ */ new Map(),
11094
+ calendarName: /* @__PURE__ */ new Map()
11093
11095
  };
11094
11096
  for (const t of plan.tags ?? []) idx.tagName.set(t.ref, t.name);
11095
11097
  for (const f of plan.customFields ?? []) {
@@ -11103,6 +11105,8 @@ function buildRefIndex(plan) {
11103
11105
  idx.pipelineName.set(p.ref, p.name);
11104
11106
  for (const st of p.stages) idx.stageName.set(st.ref, st.name);
11105
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);
11106
11110
  return idx;
11107
11111
  }
11108
11112
  function htmlWrap(text) {
@@ -11284,13 +11288,31 @@ function expandAction(action, idx, idMap) {
11284
11288
  }
11285
11289
  case "create_opportunity":
11286
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);
11287
11296
  const pName = idx.pipelineName.get(action.pipelineRef) ?? action.pipelineRef;
11288
11297
  const sName = idx.stageName.get(action.stageRef) ?? action.stageRef;
11289
- const verb = action.type === "create_opportunity" ? "Create" : "Move";
11298
+ const verb = action.type === "create_opportunity" ? "Create opportunity" : "Move opportunity";
11290
11299
  return {
11291
- kind: "manual",
11292
- logicalType: action.type,
11293
- 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
+ }
11294
11316
  };
11295
11317
  }
11296
11318
  case "goal_event": {
@@ -11316,6 +11338,42 @@ function expandAction(action, idx, idMap) {
11316
11338
  }
11317
11339
  }
11318
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
+ }
11319
11377
  function expandWorkflow(workflow, idx, idMap, gatedBy) {
11320
11378
  const nativeActions = [];
11321
11379
  const manual = [];
@@ -11333,6 +11391,13 @@ function expandWorkflow(workflow, idx, idMap, gatedBy) {
11333
11391
  }
11334
11392
  });
11335
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
+ }
11336
11401
  return {
11337
11402
  ref: workflow.ref,
11338
11403
  name: workflow.name,
@@ -11341,7 +11406,9 @@ function expandWorkflow(workflow, idx, idMap, gatedBy) {
11341
11406
  needsContent,
11342
11407
  pendingRefs: [...pendingRefs],
11343
11408
  splitInto,
11344
- gatedBy
11409
+ gatedBy,
11410
+ nativeTrigger,
11411
+ triggerManual
11345
11412
  };
11346
11413
  }
11347
11414
  function scanSection(section2, planObjects, existing) {
@@ -11494,6 +11561,10 @@ function renderReport(plan, result, ctx) {
11494
11561
  L.push(` \u2022 [funnel] Design + populate the pages of funnel "${fn.name}" (Blueprint builds the funnel + steps; page content/design is manual).`);
11495
11562
  }
11496
11563
  for (const w of result.workflows) {
11564
+ if (w.triggerManual) {
11565
+ any = true;
11566
+ L.push(` \u2022 [${w.name}] ${w.triggerManual}`);
11567
+ }
11497
11568
  for (const m of w.manual) {
11498
11569
  any = true;
11499
11570
  L.push(` \u2022 [${w.name}] ${m.reason}`);
@@ -11805,6 +11876,73 @@ async function executeBackbone(plan, deps, opts = {}) {
11805
11876
  }
11806
11877
  manual.push({ ref: fn.ref, type: "funnel-page-content", name: fn.name, reason: funnelContentReason(fn) });
11807
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
+ }
11808
11946
  return { ok: true, idMap, built, manual, deferred: deferredSections(plan) };
11809
11947
  }
11810
11948
  async function buildSimple(objects, type, list, create, nameOf, pollForNew, idMap, built) {
@@ -11839,10 +11977,8 @@ async function buildSimple(objects, type, list, create, nameOf, pollForNew, idMa
11839
11977
  }
11840
11978
  return null;
11841
11979
  }
11842
- function deferredSections(plan) {
11843
- const out = [];
11844
- if (plan.workflows?.length) out.push({ section: "workflows", count: plan.workflows.length });
11845
- return out;
11980
+ function deferredSections(_plan) {
11981
+ return [];
11846
11982
  }
11847
11983
  function msg(e) {
11848
11984
  return e instanceof Error ? e.message : String(e);
@@ -12192,6 +12328,35 @@ ${text2.slice(0, 300)}`);
12192
12328
  },
12193
12329
  deleteFunnel: async (funnelId) => {
12194
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);
12195
12360
  }
12196
12361
  };
12197
12362
  }
@@ -12250,7 +12415,7 @@ function registerIntakeToBuildTools(server2, client, builderClient) {
12250
12415
  );
12251
12416
  server2.tool(
12252
12417
  "apply_build_plan",
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.`,
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.`,
12254
12419
  {
12255
12420
  plan: import_zod53.z.record(import_zod53.z.unknown()).describe("The approved \xA75 Build Plan object."),
12256
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)."),
@@ -12343,7 +12508,7 @@ function registerIntakeToBuildTools(server2, client, builderClient) {
12343
12508
  manual: exec.manual,
12344
12509
  idMap: exec.idMap,
12345
12510
  deferred: exec.deferred,
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.",
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.",
12347
12512
  nextManualSteps: [...execManualLines, ...manualLines, ...handoffLines],
12348
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.`
12349
12514
  });
@@ -12375,6 +12540,8 @@ function registerIntakeToBuildTools(server2, client, builderClient) {
12375
12540
  autoActions: w.nativeActions.length,
12376
12541
  manualSteps: w.manual,
12377
12542
  needsContent: w.needsContent,
12543
+ triggerManual: w.triggerManual,
12544
+ triggerAutoBuilt: !!w.nativeTrigger,
12378
12545
  pendingRefs: w.pendingRefs,
12379
12546
  splitInto: w.splitInto,
12380
12547
  gatedBy: w.gatedBy,
@@ -12383,7 +12550,7 @@ function registerIntakeToBuildTools(server2, client, builderClient) {
12383
12550
  calendarsManual: result.calendarsManual,
12384
12551
  handoffs: result.handoffs,
12385
12552
  report,
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.'
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.'
12387
12554
  });
12388
12555
  } catch (error) {
12389
12556
  return errorResponse(error);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@elitedcs/ghl-mcp",
3
- "version": "3.41.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",