@elitedcs/ghl-mcp 3.34.3 → 3.34.4

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/CHANGELOG.md CHANGED
@@ -1,5 +1,28 @@
1
1
  # Changelog
2
2
 
3
+ ## 3.34.4 — Audit catches blank-render merge tags + honest guard on Update-Opportunity
4
+
5
+ - **`audit_workflows` / `validate_workflow` now flag dead `{{contact.X}}` merge tags.**
6
+ A custom field deleted or renamed while still referenced by a `{{contact.x}}`
7
+ merge tag in message copy (sms body, email html/subject, notes, notifications)
8
+ renders **blank** at send time. Unlike a dead structured id — which silently
9
+ skips the action and everything after it — this only produces an empty value, so
10
+ it is reported as a **warning**, never an error, and never flips a workflow to
11
+ "broken". Validated by custom-field key (`fieldKey`), with a generous standard-
12
+ field allowlist and nested-path skipping (`{{contact.attributionSource.x}}`) so
13
+ it never cries wolf on `{{contact.first_name}}` and friends; goes `unverified`
14
+ (not warned) if the custom-field list fails to load. Surfaced in a new
15
+ `merge_field_warnings` section + `warnings_count`. (10 regression tests; verified
16
+ against real GHL `fieldKey` shapes.)
17
+ - **`update_workflow_actions`: honest fail-fast on `internal_update_opportunity`.**
18
+ GHL's builder rejects a *synthesized* move/update-opportunity node with "action
19
+ has a corrupted type" and fails the entire save with a cryptic error. The builder
20
+ now detects a freshly-built one and throws a clear, actionable message up front
21
+ (build that one step in the GHL UI, or round-trip an existing one via
22
+ `get_workflow_full` — those keep their node id and pass through untouched). No
23
+ more one-bad-node-nukes-the-whole-save surprise. Tool + schema docs updated to
24
+ state the limitation plainly.
25
+
3
26
  ## 3.34.3 — Audit catches dead workflow hand-offs + get_social_posts 422 fix
4
27
 
5
28
  - **`audit_workflows` / `validate_workflow` now scan `add_to_workflow` targets.**
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.3",
34
+ version: "3.34.4",
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",
@@ -1206,9 +1206,15 @@ function validateActionChain(actions) {
1206
1206
  case "wait":
1207
1207
  if (!attr.startAfter) throw new Error(`Wait action "${action.name}" missing required 'startAfter' in attributes.`);
1208
1208
  break;
1209
- case "internal_update_opportunity":
1209
+ 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
+ );
1214
+ }
1210
1215
  if (!Array.isArray(attr.__customInputFields__)) throw new Error(`Internal update opportunity action "${action.name}" missing '__customInputFields__' array.`);
1211
1216
  break;
1217
+ }
1212
1218
  case "remove_from_workflow":
1213
1219
  if (!attr.workflowId || !Array.isArray(attr.workflow_id)) throw new Error(`Remove from workflow action "${action.name}" needs BOTH 'workflowId' string AND 'workflow_id' array.`);
1214
1220
  break;
@@ -5394,7 +5400,7 @@ function registerWorkflowBuilderTools(server2, client) {
5394
5400
  );
5395
5401
  server2.tool(
5396
5402
  "update_workflow_actions",
5397
- "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. 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.",
5403
+ "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.",
5398
5404
  {
5399
5405
  workflowId: import_zod33.z.string().describe("The workflow ID to update."),
5400
5406
  name: import_zod33.z.string().optional().describe("New workflow name."),
@@ -8514,6 +8520,96 @@ var ID_SHAPE = /^[A-Za-z0-9]{17,}$/;
8514
8520
  function isIdShaped(s) {
8515
8521
  return typeof s === "string" && ID_SHAPE.test(s);
8516
8522
  }
8523
+ var STANDARD_CONTACT_FIELDS = /* @__PURE__ */ new Set([
8524
+ "first_name",
8525
+ "firstname",
8526
+ "last_name",
8527
+ "lastname",
8528
+ "name",
8529
+ "full_name",
8530
+ "fullname",
8531
+ "nickname",
8532
+ "email",
8533
+ "email_lower_case",
8534
+ "phone",
8535
+ "phone_raw",
8536
+ "phone_number",
8537
+ "company_name",
8538
+ "companyname",
8539
+ "business_name",
8540
+ "organization",
8541
+ "address1",
8542
+ "address",
8543
+ "full_address",
8544
+ "street_address",
8545
+ "city",
8546
+ "state",
8547
+ "province",
8548
+ "country",
8549
+ "postal_code",
8550
+ "postalcode",
8551
+ "zip",
8552
+ "zip_code",
8553
+ "timezone",
8554
+ "time_zone",
8555
+ "date_of_birth",
8556
+ "dateofbirth",
8557
+ "dob",
8558
+ "birthday",
8559
+ "source",
8560
+ "contact_source",
8561
+ "type",
8562
+ "contact_type",
8563
+ "id",
8564
+ "contact_id",
8565
+ "contactid",
8566
+ "tags",
8567
+ "dnd",
8568
+ "dnd_status",
8569
+ "assigned_to",
8570
+ "assignedto",
8571
+ "owner",
8572
+ "contact_owner",
8573
+ "created_by",
8574
+ "updated_by",
8575
+ "date_created",
8576
+ "date_updated",
8577
+ "date_added",
8578
+ "created_at",
8579
+ "updated_at",
8580
+ "last_updated",
8581
+ "last_activity",
8582
+ "last_activity_date",
8583
+ "website",
8584
+ "url",
8585
+ "gender",
8586
+ "rating",
8587
+ "additional_emails",
8588
+ "additional_phones",
8589
+ "attributionsource",
8590
+ "attribution_source"
8591
+ ]);
8592
+ var MERGE_TAG_RE = /\{\{\s*contact\.([^}|\s]+)/gi;
8593
+ function collectMergeTagKeys(value, out) {
8594
+ if (typeof value === "string") {
8595
+ MERGE_TAG_RE.lastIndex = 0;
8596
+ let m;
8597
+ while ((m = MERGE_TAG_RE.exec(value)) !== null) {
8598
+ const raw = m[1];
8599
+ if (!raw || raw.includes(".")) continue;
8600
+ const key = raw.toLowerCase();
8601
+ if (STANDARD_CONTACT_FIELDS.has(key)) continue;
8602
+ out.add(key);
8603
+ }
8604
+ return;
8605
+ }
8606
+ if (Array.isArray(value)) {
8607
+ for (const v of value) collectMergeTagKeys(v, out);
8608
+ return;
8609
+ }
8610
+ if (value && typeof value === "object")
8611
+ for (const v of Object.values(value)) collectMergeTagKeys(v, out);
8612
+ }
8517
8613
  function extractFromTrigger(trigger, refs) {
8518
8614
  const triggerName = String(trigger.name ?? trigger.type ?? "unnamed trigger");
8519
8615
  const where = `trigger "${triggerName}"`;
@@ -8668,6 +8764,28 @@ function extractFromAction(action, refs) {
8668
8764
  break;
8669
8765
  }
8670
8766
  }
8767
+ const mergeKeys = /* @__PURE__ */ new Set();
8768
+ collectMergeTagKeys(attr, mergeKeys);
8769
+ for (const key of mergeKeys)
8770
+ refs.push({ kind: "custom_field", id: key, where: `${where} \u2192 merge tag {{contact.${key}}}`, byKey: true });
8771
+ }
8772
+ function collectCustomFieldKeys(envelope) {
8773
+ const keys = /* @__PURE__ */ new Set();
8774
+ let arr = null;
8775
+ if (Array.isArray(envelope)) arr = envelope;
8776
+ else if (envelope && typeof envelope === "object" && Array.isArray(envelope.customFields))
8777
+ arr = envelope.customFields;
8778
+ if (arr) for (const item of arr) {
8779
+ if (!item || typeof item !== "object") continue;
8780
+ const o = item;
8781
+ for (const raw of [o.fieldKey, o.key]) {
8782
+ if (typeof raw !== "string") continue;
8783
+ let k = raw.toLowerCase();
8784
+ if (k.startsWith("contact.")) k = k.slice("contact.".length);
8785
+ if (k) keys.add(k);
8786
+ }
8787
+ }
8788
+ return keys;
8671
8789
  }
8672
8790
  function collectIds(envelope, listKeys) {
8673
8791
  const ids = /* @__PURE__ */ new Set();
@@ -8746,12 +8864,13 @@ async function fetchAndBuildLookups(client, builderClient, locationId2, workflow
8746
8864
  return ids;
8747
8865
  }
8748
8866
  const custom_field = setFrom("customFields", "custom_field", ["customFields"]);
8867
+ const custom_field_keys = failed.has("customFields") ? /* @__PURE__ */ new Set() : collectCustomFieldKeys(data.customFields);
8749
8868
  const user = setFrom("users", "user", ["users"]);
8750
8869
  const form = setFrom("forms", "form", ["forms"]);
8751
8870
  const calendar = setFrom("calendars", "calendar", ["calendars"]);
8752
8871
  const survey = setFrom("surveys", "survey", ["surveys"]);
8753
8872
  status.workflow = workflowExistence.complete ? "loaded" : "incomplete";
8754
- return { pipelines, stages, custom_field, user, workflow: workflowExistence.ids, form, calendar, survey, status };
8873
+ return { pipelines, stages, custom_field, custom_field_keys, user, workflow: workflowExistence.ids, form, calendar, survey, status };
8755
8874
  }
8756
8875
  function auditOneWorkflow(workflow, selfId, lookups) {
8757
8876
  const refs = [];
@@ -8775,6 +8894,26 @@ function checkRefs(refs, selfId, lookups) {
8775
8894
  const findings = [];
8776
8895
  for (const ref of refs) {
8777
8896
  const st = lookups.status[ref.kind];
8897
+ if (ref.byKey && ref.kind === "custom_field") {
8898
+ if (st !== "loaded") {
8899
+ findings.push({
8900
+ severity: "unverified",
8901
+ category: "custom_field",
8902
+ id: ref.id,
8903
+ where: ref.where,
8904
+ message: `merge tag {{contact.${ref.id}}} could not be verified \u2014 the custom field list ${st === "incomplete" ? "is too large to fully load" : "failed to load or was unreadable"}. Re-run; not reported as broken.`
8905
+ });
8906
+ } else if (!lookups.custom_field_keys.has(ref.id)) {
8907
+ findings.push({
8908
+ severity: "warning",
8909
+ category: "custom_field",
8910
+ id: ref.id,
8911
+ where: ref.where,
8912
+ message: `merge tag {{contact.${ref.id}}} matches no standard field or custom field in this location \u2014 it will render blank. If "${ref.id}" was a custom field it was likely deleted or renamed; if it is a standard field this audit does not recognize, you can ignore this.`
8913
+ });
8914
+ }
8915
+ continue;
8916
+ }
8778
8917
  if (st !== "loaded") {
8779
8918
  findings.push({
8780
8919
  severity: "unverified",
@@ -8846,7 +8985,7 @@ function registerValidatorTools(server2, client, builderClient) {
8846
8985
  for (const t of Array.isArray(workflow.triggers) ? workflow.triggers : []) extractFromTrigger(t, refs);
8847
8986
  for (const a of Array.isArray(workflow.workflowData?.templates) ? workflow.workflowData.templates : []) extractFromAction(a, refs);
8848
8987
  if (refs.length === 0)
8849
- return jsonResponse({ workflowId, workflowName: workflow.name, status: "ok", references_scanned: 0, issues_count: 0, findings: [] });
8988
+ return jsonResponse({ workflowId, workflowName: workflow.name, status: "ok", references_scanned: 0, issues_count: 0, warnings_count: 0, findings: [] });
8850
8989
  const needWorkflows = refs.some((r) => r.kind === "workflow");
8851
8990
  const catalog = needWorkflows ? await fullWorkflowCatalog(builderClient) : { ids: /* @__PURE__ */ new Set(), complete: true };
8852
8991
  const lookups = await fetchAndBuildLookups(client, builderClient, client.defaultLocationId, { ids: catalog.ids, complete: catalog.complete });
@@ -8857,6 +8996,7 @@ function registerValidatorTools(server2, client, builderClient) {
8857
8996
  status: findings.some((f) => f.severity === "error") ? "issues_found" : "ok",
8858
8997
  references_scanned: refs.length,
8859
8998
  issues_count: findings.filter((f) => f.severity === "error").length,
8999
+ warnings_count: findings.filter((f) => f.severity === "warning" && f.category === "custom_field").length,
8860
9000
  findings
8861
9001
  };
8862
9002
  return jsonResponse(report);
@@ -8898,6 +9038,8 @@ function registerValidatorTools(server2, client, builderClient) {
8898
9038
  }
8899
9039
  const withErrors = results.filter((r) => r.findings.some((f) => f.severity === "error")).map((r) => ({ workflowId: r.id, workflowName: r.name, status: r.status, errors: r.findings.filter((f) => f.severity === "error") })).sort((a, b) => (a.status === "published" ? -1 : 1) - (b.status === "published" ? -1 : 1));
8900
9040
  const errorsTotal = withErrors.reduce((n, w) => n + w.errors.length, 0);
9041
+ const withWarnings = results.filter((r) => r.findings.some((f) => f.severity === "warning" && f.category === "custom_field")).map((r) => ({ workflowId: r.id, workflowName: r.name, status: r.status, warnings: r.findings.filter((f) => f.severity === "warning" && f.category === "custom_field") })).sort((a, b) => (a.status === "published" ? -1 : 1) - (b.status === "published" ? -1 : 1));
9042
+ const warningsTotal = withWarnings.reduce((n, w) => n + w.warnings.length, 0);
8901
9043
  const unverifiedCats = ALL_CATEGORIES.filter((c) => lookups.status[c] !== "loaded");
8902
9044
  const unverifiedRefs = results.reduce((n, r) => n + r.findings.filter((f) => f.severity === "unverified").length, 0);
8903
9045
  return jsonResponse({
@@ -8909,17 +9051,21 @@ function registerValidatorTools(server2, client, builderClient) {
8909
9051
  capped: catalog.rows.length > SCAN_CAP,
8910
9052
  workflows_with_errors: withErrors.length,
8911
9053
  errors_total: errorsTotal,
9054
+ workflows_with_merge_field_warnings: withWarnings.length,
9055
+ merge_field_warnings_total: warningsTotal,
8912
9056
  workflows_unscannable: unscannable.length,
8913
9057
  workflows_zero_references: zeroRefCount,
8914
9058
  unverified: { categories_unloaded: unverifiedCats, references_unverified: unverifiedRefs },
8915
9059
  status: errorsTotal > 0 ? "issues_found" : "ok"
8916
9060
  },
8917
9061
  workflows_with_issues: withErrors,
9062
+ merge_field_warnings: withWarnings,
8918
9063
  unscannable,
8919
9064
  notes: [
8920
9065
  ...catalog.complete ? [] : ["Workflow catalog exceeded the pagination backstop \u2014 some workflow-id references shown as unverified."],
8921
9066
  ...catalog.rows.length > SCAN_CAP ? [`Only the first ${SCAN_CAP} workflows were scanned (account has ${catalog.rows.length}).`] : [],
8922
9067
  ...unverifiedCats.length ? [`Could not fully load: ${unverifiedCats.join(", ")} \u2014 references to those are 'unverified', not 'broken'. Re-run.`] : [],
9068
+ ...warningsTotal ? [`${warningsTotal} {{contact.X}} merge tag(s) reference a field that no longer exists \u2014 these render BLANK in the message but do NOT stop the workflow (warning, not a break). See merge_field_warnings.`] : [],
8923
9069
  "workflow_goal, goto, and unrecognized condition types are not deeply checked in this version."
8924
9070
  ]
8925
9071
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@elitedcs/ghl-mcp",
3
- "version": "3.34.3",
3
+ "version": "3.34.4",
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": "Use action type internal_update_opportunity. 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": "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."
169
169
  },
170
170
 
171
171
  "_if_else_branching": {