@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 +236 -29
- package/package.json +1 -1
- package/templates/action-schemas.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.
|
|
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
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
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
|
|
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)
|
|
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: "
|
|
11252
|
-
|
|
11253
|
-
|
|
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(
|
|
11803
|
-
|
|
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
|
|
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,
|
|
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:
|
|
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.
|
|
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": "
|
|
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": {
|