@elitedcs/ghl-mcp 3.69.0 → 3.71.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/CHANGELOG.md CHANGED
@@ -1,5 +1,63 @@
1
1
  # Changelog
2
2
 
3
+ ## 3.71.0 — a task you can close
4
+
5
+ A subscriber's Claude created a callback task on a contact and, come Monday, could not
6
+ close it. It said so plainly: the server can create tasks but has no way to update or
7
+ complete one, tick it off by hand. It was right. `create_contact_task` and
8
+ `get_contact_tasks` were the whole task surface.
9
+
10
+ **Three tools close the loop.** `complete_contact_task` marks a task done, or reopens it
11
+ with `completed: false`. `update_contact_task` changes a task's title, description, due
12
+ date, assignee or completed state, and sends only the fields you pass, so changing a due
13
+ date never blanks the title; an update with no fields is refused before any request goes
14
+ out. `delete_contact_task` removes a task for good and, like every delete in this server,
15
+ asks for `confirm: "DELETE"`. All three use the routes in
16
+ GoHighLevel's published contacts spec and were proven live before release on a throwaway
17
+ contact in a sandbox account: create, complete, read back completed, update, reopen,
18
+ delete, read back gone, contact removed (`docs/proofs/2026-08-26-task-lifecycle.md`).
19
+
20
+ Counts: 245 tools across 51 modules (was 242). The free read-only tier is unchanged at
21
+ 111; the three new tools are writes and answer with upgrade information there. Restart
22
+ Claude after the update to see them.
23
+
24
+ ## 3.70.0 — a clean validation now means something
25
+
26
+ A subscriber built an Instagram-comment-to-DM workflow, ran `validate_workflow`, got
27
+ back "0 issues, 0 warnings", published it, and watched the Create/Update Opportunity
28
+ step do nothing: no card in the pipeline, no card on the contact, and the workflow
29
+ carried on to the next step as if it had worked. They were right about why. The step
30
+ had only a pipeline and a stage. GoHighLevel's own documentation lists Opportunity
31
+ Name, Source and Status as mandatory for the combined Create/Update Opportunity step
32
+ to create anything; with only pipeline and stage it moves a card the contact already
33
+ has, and a brand-new lead has none. The
34
+ validator was checking that every id existed. It never asked whether the step could
35
+ do what its name says.
36
+
37
+ **`validate_workflow` and `audit_workflows` now check what a step can do.** A
38
+ Create/Update Opportunity step with only pipeline + stage and no opportunity in
39
+ context comes back as a warning that says exactly that and names the fix. "In
40
+ context" is worked out from the workflow itself: a create step earlier on the same
41
+ path, a Find Opportunity step whose "Opportunity Found" branch the step sits in, or a
42
+ trigger that fires on an opportunity. A step that carries an Opportunity Name and a
43
+ Status is taken as one that creates and is left alone; a Name with no Status is
44
+ reported as unverified with the thing to check. Before release the check ran against
45
+ every workflow in five of our own accounts (106 workflows) and flagged none; the run
46
+ is recorded in `docs/proofs/2026-08-26-validate-runtime-noop.md`. A path the validator
47
+ cannot trace, or a field this version does not know, is reported as unverified rather
48
+ than passed. `warnings_count` now includes these findings, the report says how many
49
+ steps were checked (`actions_checked`), and `audit_workflows` lists them under
50
+ `shape_warnings` with counts in its summary; `status` still flips only on an error,
51
+ never on a warning. A `task_notification` step spelled with the underscore, which
52
+ saves, validates and is skipped at runtime, is that kind of error now.
53
+
54
+ **Claude now builds the right node.** GoHighLevel has separated Create Opportunity
55
+ from Update Opportunity and is phasing the combined action out for new workflows. The
56
+ builder knows the newer Create Opportunity node (`internal_create_opportunity`),
57
+ validates it, normalizes it to the shape proven to create a card, and the reference
58
+ material Claude reads before building a workflow now says which node creates a card
59
+ and which one moves it, instead of pointing both jobs at the same node. (#57)
60
+
3
61
  ## 3.69.0 — the cockpit hardened, and the plan you approved is the plan that runs
4
62
 
5
63
  Two items in this release exist because someone tried to break the cockpit
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # GHL Command — GoHighLevel MCP Server
2
2
 
3
- **Full GoHighLevel API access for Claude.** 242 tools across 51 modules — manage contacts, conversations, pipelines, calendars, funnels, workflows, invoices, custom objects, webhooks, and more. **Includes full workflow builder, funnel/page editor, form builder, pipeline builder, bulk operations, account export, and workflow cloning** — capabilities no other GHL tool offers. **Multi-tenant:** one install can run the workflow builder across multiple clients' GHL accounts.
3
+ **Full GoHighLevel API access for Claude.** 245 tools across 51 modules — manage contacts, conversations, pipelines, calendars, funnels, workflows, invoices, custom objects, webhooks, and more. **Includes full workflow builder, funnel/page editor, form builder, pipeline builder, bulk operations, account export, and workflow cloning** — capabilities no other GHL tool offers. **Multi-tenant:** one install can run the workflow builder across multiple clients' GHL accounts.
4
4
 
5
5
  **Distributed via npm as [`@elitedcs/ghl-mcp`](https://www.npmjs.com/package/@elitedcs/ghl-mcp).** Buyers install with one config block — no git, no Node.js setup, no terminal commands. Updates flow automatically (`npx @latest` re-resolves on every Claude restart).
6
6
 
@@ -135,7 +135,7 @@ Run setup_ghl_mcp to activate GHL Command:
135
135
  ghl_location_id: YOUR_LOCATION_ID
136
136
  ```
137
137
 
138
- Approve the tool call. Server validates your license, verifies your GHL credentials, writes them to a per-user config file. **Quit Claude one more time and reopen** — the full core toolset is now unlocked (242 tools total with the optional Workflow Builder Firebase add-on).
138
+ Approve the tool call. Server validates your license, verifies your GHL credentials, writes them to a per-user config file. **Quit Claude one more time and reopen** — the full core toolset is now unlocked (245 tools total with the optional Workflow Builder Firebase add-on).
139
139
 
140
140
  ### 4. Try it
141
141
 
@@ -175,7 +175,7 @@ https://app.gohighlevel.com/v2/location/YOUR_LOCATION_ID/dashboard
175
175
 
176
176
  ## Enable the Workflow Builder
177
177
 
178
- The builder + cloner + validator tools (workflow builder, funnel builder, form builder, pipeline builder, workflow cloner, validate_workflow) use GHL's internal API and require Firebase credentials. Without them, the other 187 tools work fine — you just won't have workflow/funnel/form/pipeline editing. This is the flagship feature: do this step.
178
+ The builder + cloner + validator tools (workflow builder, funnel builder, form builder, pipeline builder, workflow cloner, validate_workflow) use GHL's internal API and require Firebase credentials. Without them, the other 190 tools work fine — you just won't have workflow/funnel/form/pipeline editing. This is the flagship feature: do this step.
179
179
 
180
180
  **One-click capture (v3.49.0+, the supported path):**
181
181
 
@@ -269,7 +269,7 @@ Highlights (full guide: [`docs/HEADLESS.md`](docs/HEADLESS.md)):
269
269
 
270
270
  > **v3.10.0 adds Email Templates** (`list_email_templates`, `create_email_template`, `update_email_template`) — Claude can now create new HTML email templates and save content into them via the public API. Templates power both standalone marketing emails and workflow email actions. Delete + rename remain UI-only (no public-API endpoint exists).
271
271
 
272
- ### CRM & Contacts (15 tools)
272
+ ### CRM & Contacts (18 tools)
273
273
 
274
274
  | Tool | What It Does |
275
275
  |---|---|
@@ -283,6 +283,9 @@ Highlights (full guide: [`docs/HEADLESS.md`](docs/HEADLESS.md)):
283
283
  | `remove_contact_tags` | Remove tags from a contact |
284
284
  | `get_contact_tasks` | List tasks for a contact |
285
285
  | `create_contact_task` | Create a task on a contact |
286
+ | `complete_contact_task` | Mark a task done (or reopen it) |
287
+ | `update_contact_task` | Change a task's title, description, due date, assignee or state |
288
+ | `delete_contact_task` | Delete a task from a contact |
286
289
  | `get_contact_notes` | List notes on a contact |
287
290
  | `create_contact_note` | Add a note to a contact |
288
291
  | `get_contact_appointments` | List a contact's appointments |
@@ -798,7 +801,7 @@ Source repo is private. Contributors need an invitation from `drjerryrelth`. The
798
801
 
799
802
  ### Reducing context / token usage
800
803
 
801
- Every registered MCP tool's schema is shipped to the model on every message. With 242 tools that's a meaningful per-message context cost even in chats that never touch GHL. If you only use a slice of GHL Command, restrict the tool surface with `GHL_ENABLED_MODULES` and/or `GHL_ENABLED_TOOLS`:
804
+ Every registered MCP tool's schema is shipped to the model on every message. With 245 tools that's a meaningful per-message context cost even in chats that never touch GHL. If you only use a slice of GHL Command, restrict the tool surface with `GHL_ENABLED_MODULES` and/or `GHL_ENABLED_TOOLS`:
802
805
 
803
806
  ```jsonc
804
807
  // Claude Desktop config — enable whole modules
package/dist/index.js CHANGED
@@ -1118,6 +1118,35 @@ function normalizeRemoveFromWorkflowAction(action) {
1118
1118
  }
1119
1119
  return { ...action, attributes: next };
1120
1120
  }
1121
+ function normalizeInternalCreateOpportunityAction(action) {
1122
+ if (action.type !== "internal_create_opportunity") return action;
1123
+ const attrIn = action.attributes && typeof action.attributes === "object" ? action.attributes : {};
1124
+ const fieldsIn = Array.isArray(attrIn.__customInputFields__) ? attrIn.__customInputFields__ : [];
1125
+ let pipelineId = typeof attrIn.pipelineId === "string" ? attrIn.pipelineId : void 0;
1126
+ const __customInputFields__ = [];
1127
+ for (const raw of fieldsIn) {
1128
+ const f = raw && typeof raw === "object" ? raw : {};
1129
+ if (f.filterField === "pipelineId") {
1130
+ if (!pipelineId && typeof f.value === "string") pipelineId = f.value;
1131
+ continue;
1132
+ }
1133
+ __customInputFields__.push({
1134
+ ...f,
1135
+ __customInputs__: f.__customInputs__ && typeof f.__customInputs__ === "object" ? f.__customInputs__ : {},
1136
+ dataType: typeof f.dataType === "string" ? f.dataType : "SINGLE_OPTIONS",
1137
+ valueFieldType: typeof f.valueFieldType === "string" ? f.valueFieldType : "select"
1138
+ });
1139
+ }
1140
+ const attributes = {
1141
+ ...attrIn,
1142
+ type: "internal_create_opportunity",
1143
+ ...pipelineId ? { pipelineId } : {},
1144
+ __customInputs__: attrIn.__customInputs__ && typeof attrIn.__customInputs__ === "object" ? attrIn.__customInputs__ : {},
1145
+ __customInputFields__
1146
+ };
1147
+ delete attributes.workflowsActionType;
1148
+ return { ...action, workflowsActionType: "INTERNAL", attributes };
1149
+ }
1121
1150
  function normalizeInternalUpdateOpportunityAction(action) {
1122
1151
  if (action.type !== "internal_update_opportunity") return action;
1123
1152
  const attrIn = action.attributes && typeof action.attributes === "object" ? action.attributes : {};
@@ -1202,6 +1231,26 @@ function validateActionChain(actions, existingIds) {
1202
1231
  }
1203
1232
  break;
1204
1233
  }
1234
+ case "internal_create_opportunity": {
1235
+ const isRoundTripped = hasId(action) && (existingIds ? existingIds.has(action.id) : true);
1236
+ if (!isRoundTripped) {
1237
+ const cif = Array.isArray(attr.__customInputFields__) ? attr.__customInputFields__ : [];
1238
+ const listed = (ff) => cif.find((f) => f && f.filterField === ff);
1239
+ const pipelineId = typeof attr.pipelineId === "string" ? attr.pipelineId : listed("pipelineId")?.value;
1240
+ if (!isIdShaped(pipelineId)) {
1241
+ throw new Error(
1242
+ `Create opportunity action "${action.name}" needs a valid 'pipelineId' (an id, not a name) in attributes. Use get_pipelines / list_pipelines_full to find it.`
1243
+ );
1244
+ }
1245
+ const stage = listed("pipelineStageId");
1246
+ if (!stage || !isIdShaped(stage.value)) {
1247
+ throw new Error(
1248
+ `Create opportunity action "${action.name}" needs a valid pipelineStageId 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.)`
1249
+ );
1250
+ }
1251
+ }
1252
+ break;
1253
+ }
1205
1254
  case "remove_from_workflow":
1206
1255
  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.`);
1207
1256
  break;
@@ -1941,7 +1990,7 @@ ${errorBody}`
1941
1990
  */
1942
1991
  buildActionChain(actions, existingIds) {
1943
1992
  validateActionChain(actions, existingIds);
1944
- const linked = actions.map(normalizeRemoveFromWorkflowAction).map(normalizeInternalUpdateOpportunityAction).map((action, i) => {
1993
+ const linked = actions.map(normalizeRemoveFromWorkflowAction).map(normalizeInternalUpdateOpportunityAction).map(normalizeInternalCreateOpportunityAction).map((action, i) => {
1945
1994
  const copy = { ...action };
1946
1995
  if (!copy.id) {
1947
1996
  copy.id = crypto.randomUUID();
@@ -2683,6 +2732,15 @@ var init_contact_window = __esm({
2683
2732
  });
2684
2733
 
2685
2734
  // src/tools/contacts.ts
2735
+ function buildUpdateTaskBody(input) {
2736
+ const body = {};
2737
+ if (input.title !== void 0) body.title = input.title;
2738
+ if (input.body !== void 0) body.body = input.body;
2739
+ if (input.dueDate !== void 0) body.dueDate = input.dueDate;
2740
+ if (input.completed !== void 0) body.completed = input.completed;
2741
+ if (input.assignedTo !== void 0) body.assignedTo = input.assignedTo;
2742
+ return Object.keys(body).length === 0 ? null : body;
2743
+ }
2686
2744
  function registerContactTools(server2, client) {
2687
2745
  safeTool(
2688
2746
  server2,
@@ -2963,6 +3021,57 @@ function registerContactTools(server2, client) {
2963
3021
  });
2964
3022
  }
2965
3023
  );
3024
+ safeTool(
3025
+ server2,
3026
+ "complete_contact_task",
3027
+ "Mark a contact's task as done (or reopen it with completed=false). Find the task ID with get_contact_tasks; create_contact_task also returns it. Use this to tick off a task created earlier.",
3028
+ {
3029
+ contactId: import_zod9.z.string().min(1).describe("The ID of the contact the task belongs to."),
3030
+ taskId: import_zod9.z.string().min(1).describe("The ID of the task (from get_contact_tasks or create_contact_task)."),
3031
+ completed: import_zod9.z.boolean().optional().describe("true (default) marks the task completed; false reopens a completed task.")
3032
+ },
3033
+ async ({ contactId, taskId, completed }) => {
3034
+ return client.put(`/contacts/${contactId}/tasks/${taskId}/completed`, {
3035
+ body: { completed: completed ?? true }
3036
+ });
3037
+ }
3038
+ );
3039
+ safeTool(
3040
+ server2,
3041
+ "update_contact_task",
3042
+ "Change an existing task on a contact: title, description, due date, assignee, or completed state. Only the fields you pass are changed. To simply tick a task off, complete_contact_task is the direct route.",
3043
+ {
3044
+ contactId: import_zod9.z.string().min(1).describe("The ID of the contact the task belongs to."),
3045
+ taskId: import_zod9.z.string().min(1).describe("The ID of the task to update (from get_contact_tasks)."),
3046
+ title: import_zod9.z.string().min(1).optional().describe("New title / summary of the task (cannot be blank)."),
3047
+ body: import_zod9.z.string().optional().describe("New detailed description of the task."),
3048
+ dueDate: import_zod9.z.string().refine((d) => !Number.isNaN(Date.parse(d)), "dueDate must be an ISO 8601 date or date-time").optional().describe("New due date in ISO 8601 format (e.g. 2026-08-31T16:00:00.000Z)."),
3049
+ completed: import_zod9.z.boolean().optional().describe("Set the completed state of the task."),
3050
+ assignedTo: import_zod9.z.string().optional().describe("User ID to reassign the task to.")
3051
+ },
3052
+ async ({ contactId, taskId, title, body: taskBody, dueDate, completed, assignedTo }) => {
3053
+ const reqBody = buildUpdateTaskBody({ title, body: taskBody, dueDate, completed, assignedTo });
3054
+ if (!reqBody) {
3055
+ throw new Error(
3056
+ "Nothing to update: pass at least one of title, body, dueDate, completed, assignedTo."
3057
+ );
3058
+ }
3059
+ return client.put(`/contacts/${contactId}/tasks/${taskId}`, { body: reqBody });
3060
+ }
3061
+ );
3062
+ safeTool(
3063
+ server2,
3064
+ "delete_contact_task",
3065
+ "Delete a task from a contact. Permanent. To keep the record and just close it, use complete_contact_task instead.",
3066
+ {
3067
+ contactId: import_zod9.z.string().min(1).describe("The ID of the contact the task belongs to."),
3068
+ taskId: import_zod9.z.string().min(1).describe("The ID of the task to delete (from get_contact_tasks)."),
3069
+ confirm: import_zod9.z.literal("DELETE").describe("Must pass 'DELETE' to confirm this destructive action.")
3070
+ },
3071
+ async ({ contactId, taskId }) => {
3072
+ return client.delete(`/contacts/${contactId}/tasks/${taskId}`);
3073
+ }
3074
+ );
2966
3075
  safeTool(
2967
3076
  server2,
2968
3077
  "get_contact_notes",
@@ -9748,7 +9857,7 @@ Note: Firebase credentials rejected (${fb.error}).`;
9748
9857
  const telemetryLine = telemetryDisabled(process.env) ? "" : `
9749
9858
 
9750
9859
  ${TELEMETRY_DISCLOSURE}`;
9751
- const finishedCount = isFree ? "111" : "242";
9860
+ const finishedCount = isFree ? "111" : "245";
9752
9861
  const freeTip = isFree ? `
9753
9862
 
9754
9863
  Free tier: read-only. Write tools stay visible but answer with upgrade info instead of acting. Full version ($97/mo founding rate) upgrades in place \u2014 same install, you only swap the license key: https://ghlcommand.com` : "";
@@ -9793,7 +9902,7 @@ Free tier: read-only. Write tools stay visible but answer with upgrade info inst
9793
9902
  function registerEnableWorkflowBuilderTool(server2) {
9794
9903
  server2.tool(
9795
9904
  "enable_workflow_builder",
9796
- "Add Firebase credentials to an existing GHL Command install to unlock 55 additional tools across the internal-API modules: workflow builder (create/edit/clone/delete/publish/validate workflows, build_if_else_branch, build_goal_event, get_trigger_registry), funnel + page builder, form builder, pipeline builder, workflow cloner, smart lists, reputation, email campaigns, email templates, and memberships, plus the pre-deploy validator. On the FREE tier this same login unlocks the read-only auditor suite (audit_workflows, validate_workflow, full-detail workflow/funnel/pipeline reads). Requires you've already run setup_ghl_mcp. EASIEST PATH: run `capture_firebase_interactive` instead \u2014 a Chrome window opens, you log into GHL, zero pasting. Use THIS tool when you have JSON from `auto_capture_firebase_script` (console-paste path) to put in `firebase_paste`, or the three manual DevTools fields. Tool count goes from 187 to 242 after the next Claude restart.",
9905
+ "Add Firebase credentials to an existing GHL Command install to unlock 55 additional tools across the internal-API modules: workflow builder (create/edit/clone/delete/publish/validate workflows, build_if_else_branch, build_goal_event, get_trigger_registry), funnel + page builder, form builder, pipeline builder, workflow cloner, smart lists, reputation, email campaigns, email templates, and memberships, plus the pre-deploy validator. On the FREE tier this same login unlocks the read-only auditor suite (audit_workflows, validate_workflow, full-detail workflow/funnel/pipeline reads). Requires you've already run setup_ghl_mcp. EASIEST PATH: run `capture_firebase_interactive` instead \u2014 a Chrome window opens, you log into GHL, zero pasting. Use THIS tool when you have JSON from `auto_capture_firebase_script` (console-paste path) to put in `firebase_paste`, or the three manual DevTools fields. Tool count goes from 190 to 245 after the next Claude restart.",
9797
9906
  {
9798
9907
  // v3.25.0: one-paste path. Tool runs `auto_capture_firebase_script` to
9799
9908
  // get the console script; the script returns a JSON object that pastes
@@ -12881,6 +12990,109 @@ var init_template_deployer = __esm({
12881
12990
  });
12882
12991
 
12883
12992
  // src/tools/validators.ts
12993
+ function traceOpportunityContext(node, byId2) {
12994
+ let cur = node;
12995
+ const seen = /* @__PURE__ */ new Set();
12996
+ for (let hops = 0; hops < MAX_TRACE_HOPS; hops++) {
12997
+ const pk = cur.parentKey;
12998
+ if (typeof pk !== "string" || !pk) return "none";
12999
+ if (seen.has(pk)) return "dangling";
13000
+ seen.add(pk);
13001
+ const parent = byId2.get(pk);
13002
+ if (!parent) return "dangling";
13003
+ const ptype = typeof parent.type === "string" ? parent.type : "";
13004
+ if (OPPORTUNITY_CREATING_TYPES.has(ptype)) return "create";
13005
+ if (ptype === "transition") {
13006
+ const ownerId = typeof parent.parentKey === "string" ? parent.parentKey : typeof parent.parent === "string" ? parent.parent : "";
13007
+ const owner = ownerId ? byId2.get(ownerId) : void 0;
13008
+ if (owner && owner.type === "find_opportunity") {
13009
+ const meta = parent.attributes?.meta ?? {};
13010
+ const label = `${typeof parent.name === "string" ? parent.name : ""} ${typeof meta.__branchKey__ === "string" ? meta.__branchKey__ : ""}`.toLowerCase();
13011
+ if (label.includes("opportunity not found")) return "not_found";
13012
+ if (label.includes("opportunity found")) return "found";
13013
+ }
13014
+ }
13015
+ cur = parent;
13016
+ }
13017
+ return "dangling";
13018
+ }
13019
+ function checkActionShapes(workflow) {
13020
+ const findings = [];
13021
+ const actions = Array.isArray(workflow.workflowData?.templates) ? workflow.workflowData.templates : [];
13022
+ const triggers = Array.isArray(workflow.triggers) ? workflow.triggers : [];
13023
+ const triggerGivesOpportunity = triggers.some((t) => typeof t.type === "string" && OPPORTUNITY_CONTEXT_TRIGGERS.has(t.type));
13024
+ const byId2 = /* @__PURE__ */ new Map();
13025
+ for (const a of actions) if (typeof a.id === "string") byId2.set(a.id, a);
13026
+ for (const a of actions) {
13027
+ const type = typeof a.type === "string" ? a.type : "unknown";
13028
+ const name = typeof a.name === "string" ? a.name : "unnamed action";
13029
+ const id = typeof a.id === "string" ? a.id : name;
13030
+ const where = `action "${name}" (${type})`;
13031
+ if (type === "task_notification") {
13032
+ findings.push({
13033
+ severity: "error",
13034
+ category: "action_shape",
13035
+ id,
13036
+ where,
13037
+ message: `${where} saves and validates but is SKIPPED at runtime \u2014 the type must be "task-notification" (hyphen). Re-save it with the hyphenated type.`
13038
+ });
13039
+ continue;
13040
+ }
13041
+ if (type !== "internal_update_opportunity") continue;
13042
+ const attr = a.attributes ?? {};
13043
+ const cif = Array.isArray(attr.__customInputFields__) ? attr.__customInputFields__ : [];
13044
+ const keys = cif.map((f) => f && typeof f.filterField === "string" ? f.filterField : "").filter(Boolean);
13045
+ const has = (k) => cif.some((f) => f && f.filterField === k && typeof f.value === "string" && f.value.trim() !== "");
13046
+ if (has("name") && has("status")) continue;
13047
+ if (has("name")) {
13048
+ findings.push({
13049
+ severity: "unverified",
13050
+ category: "action_shape",
13051
+ id,
13052
+ where,
13053
+ message: `${where} carries an opportunity name but no status \u2014 GoHighLevel lists Name, Source and Status as mandatory to create a card. Open the step in the GHL builder and confirm Status (and Source) are set, or use Create Opportunity (internal_create_opportunity).`
13054
+ });
13055
+ continue;
13056
+ }
13057
+ const unknown = keys.filter((k) => !KNOWN_UPDATE_OPP_FIELDS.has(k));
13058
+ if (unknown.length) {
13059
+ findings.push({
13060
+ severity: "unverified",
13061
+ category: "action_shape",
13062
+ id,
13063
+ where,
13064
+ message: `${where} carries field(s) this version does not know (${unknown.join(", ")}) \u2014 could not judge whether it can create an opportunity.`
13065
+ });
13066
+ continue;
13067
+ }
13068
+ const ctx = traceOpportunityContext(a, byId2);
13069
+ if (ctx === "create" || ctx === "found") continue;
13070
+ if (ctx === "dangling") {
13071
+ findings.push({
13072
+ severity: "unverified",
13073
+ category: "action_shape",
13074
+ id,
13075
+ where,
13076
+ message: `${where} could not be traced back to the trigger (a parentKey points to a missing step) \u2014 could not judge whether an opportunity is in context.`
13077
+ });
13078
+ continue;
13079
+ }
13080
+ if (ctx === "none" && triggerGivesOpportunity) continue;
13081
+ findings.push({
13082
+ severity: "warning",
13083
+ category: "action_shape",
13084
+ id,
13085
+ where,
13086
+ message: `${where} cannot create an opportunity: it has only pipeline + stage${has("status") ? " + status" : ""}. It moves a card the contact already has in that pipeline; a contact without one (a new lead) gets no card and the workflow continues as if it worked. To create a card use Create Opportunity (internal_create_opportunity); to move one, put Find Opportunity before this step or trigger on the opportunity. GoHighLevel is phasing the combined Create/Update action out.`
13087
+ });
13088
+ }
13089
+ return findings;
13090
+ }
13091
+ function summarizeFindings(findings) {
13092
+ const issues = findings.filter((f) => f.severity === "error").length;
13093
+ const warnings = findings.filter((f) => f.severity === "warning" && (f.category === "custom_field" || f.category === "action_shape")).length;
13094
+ return { status: issues > 0 ? "issues_found" : "ok", issues_count: issues, warnings_count: warnings };
13095
+ }
12884
13096
  function collectMergeTagKeys(value, out) {
12885
13097
  if (typeof value === "string") {
12886
13098
  MERGE_TAG_RE.lastIndex = 0;
@@ -13294,28 +13506,29 @@ function registerValidatorTools(server2, client, builderClient) {
13294
13506
  }
13295
13507
  server2.tool(
13296
13508
  "validate_workflow",
13297
- "Pre-flight ID validation for ONE deployed GHL workflow. Scans every trigger and action for references to pipelines, pipeline stages, custom fields, users, workflows, forms, calendars, and surveys; verifies each ID exists in the current location. Use BEFORE publish_workflow when a workflow was edited, or when a published workflow stops behaving. Catches the silent-failure bug where invalid IDs make GHL skip all subsequent actions. Never reports a false break \u2014 anything it cannot fully verify is marked 'unverified', not 'error'.",
13509
+ "Pre-flight validation for ONE deployed GHL workflow. Also flags steps that cannot do what their name says at runtime (a Create/Update Opportunity step with only pipeline + stage cannot create a card; task_notification is skipped) \u2014 read every finding with severity warning, not just issues_count. Scans every trigger and action for references to pipelines, pipeline stages, custom fields, users, workflows, forms, calendars, and surveys; verifies each ID exists in the current location. Use BEFORE publish_workflow when a workflow was edited, or when a published workflow stops behaving. Catches the silent-failure bug where invalid IDs make GHL skip all subsequent actions. Never reports a false break \u2014 anything it cannot fully verify is marked 'unverified', not 'error'.",
13298
13510
  { workflowId: import_zod53.z.string().describe("The workflow ID to validate.") },
13299
13511
  async ({ workflowId }) => {
13300
13512
  try {
13301
13513
  const workflow = await builderClient.getWorkflow(workflowId);
13302
13514
  if (!workflow) return errorResponse(new Error(`Workflow ${workflowId} not found`));
13303
13515
  const refs = [];
13516
+ const shape = checkActionShapes(workflow);
13517
+ const actionsChecked = Array.isArray(workflow.workflowData?.templates) ? workflow.workflowData.templates.length : 0;
13304
13518
  for (const t of Array.isArray(workflow.triggers) ? workflow.triggers : []) extractFromTrigger(t, refs);
13305
13519
  for (const a of Array.isArray(workflow.workflowData?.templates) ? workflow.workflowData.templates : []) extractFromAction(a, refs);
13306
13520
  if (refs.length === 0)
13307
- return jsonResponse({ workflowId, workflowName: workflow.name, status: "ok", references_scanned: 0, issues_count: 0, warnings_count: 0, findings: [] });
13521
+ return jsonResponse({ workflowId, workflowName: workflow.name, ...summarizeFindings(shape), references_scanned: 0, actions_checked: actionsChecked, findings: shape });
13308
13522
  const needWorkflows = refs.some((r) => r.kind === "workflow");
13309
13523
  const catalog = needWorkflows ? await fullWorkflowCatalog(builderClient) : { ids: /* @__PURE__ */ new Set(), complete: true };
13310
13524
  const lookups = await fetchAndBuildLookups(client, builderClient, client.defaultLocationId, { ids: catalog.ids, complete: catalog.complete });
13311
- const findings = checkRefs(refs, workflowId, lookups);
13525
+ const findings = [...shape, ...checkRefs(refs, workflowId, lookups)];
13312
13526
  const report = {
13313
13527
  workflowId,
13314
13528
  workflowName: workflow.name,
13315
- status: findings.some((f) => f.severity === "error") ? "issues_found" : "ok",
13529
+ ...summarizeFindings(findings),
13316
13530
  references_scanned: refs.length,
13317
- issues_count: findings.filter((f) => f.severity === "error").length,
13318
- warnings_count: findings.filter((f) => f.severity === "warning" && f.category === "custom_field").length,
13531
+ actions_checked: actionsChecked,
13319
13532
  findings
13320
13533
  };
13321
13534
  return jsonResponse(report);
@@ -13326,7 +13539,7 @@ function registerValidatorTools(server2, client, builderClient) {
13326
13539
  );
13327
13540
  server2.tool(
13328
13541
  "audit_workflows",
13329
- "Account-wide silent-failure audit: scans EVERY workflow in the current location for references to pipelines/stages/custom-fields/users/workflows/forms/calendars/surveys that don't exist \u2014 the GHL bug where one bad ID silently kills that action and all actions after it. Returns a prioritized report of what's broken, what couldn't be scanned, and what couldn't be fully verified. Conservative: never reports a false break (uncertain checks are 'unverified', not 'broken'). Read-only.",
13542
+ "Account-wide silent-failure audit: also flags steps that cannot do what their name says (a Create/Update Opportunity step with only pipeline + stage cannot create a card) under shape_warnings \u2014 read them. Scans EVERY workflow in the current location for references to pipelines/stages/custom-fields/users/workflows/forms/calendars/surveys that don't exist \u2014 the GHL bug where one bad ID silently kills that action and all actions after it. Returns a prioritized report of what's broken, what couldn't be scanned, and what couldn't be fully verified. Conservative: never reports a false break (uncertain checks are 'unverified', not 'broken'). Read-only.",
13330
13543
  {},
13331
13544
  async () => {
13332
13545
  try {
@@ -13348,7 +13561,7 @@ function registerValidatorTools(server2, client, builderClient) {
13348
13561
  const wf = await builderClient.getWorkflow(row.id);
13349
13562
  const refs = auditOneWorkflow(wf, row.id, lookups);
13350
13563
  if (refs.length === 0) zeroRefCount++;
13351
- const findings = checkRefs(refs, row.id, lookups);
13564
+ const findings = [...checkActionShapes(wf), ...checkRefs(refs, row.id, lookups)];
13352
13565
  results.push({ id: row.id, name: wf.name ?? row.name, status: wf.status, refs: refs.length, findings });
13353
13566
  } catch (e) {
13354
13567
  unscannable.push({ id: row.id, name: row.name, reason: e instanceof Error ? e.message : String(e) });
@@ -13359,6 +13572,8 @@ function registerValidatorTools(server2, client, builderClient) {
13359
13572
  const errorsTotal = withErrors.reduce((n, w) => n + w.errors.length, 0);
13360
13573
  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));
13361
13574
  const warningsTotal = withWarnings.reduce((n, w) => n + w.warnings.length, 0);
13575
+ const withShapeWarnings = results.filter((r) => r.findings.some((f) => f.severity === "warning" && f.category === "action_shape")).map((r) => ({ workflowId: r.id, workflowName: r.name, status: r.status, warnings: r.findings.filter((f) => f.severity === "warning" && f.category === "action_shape") })).sort((a, b) => (a.status === "published" ? -1 : 1) - (b.status === "published" ? -1 : 1));
13576
+ const shapeWarningsTotal = withShapeWarnings.reduce((n, w) => n + w.warnings.length, 0);
13362
13577
  const unverifiedCats = ALL_CATEGORIES.filter((c) => lookups.status[c] !== "loaded");
13363
13578
  const unverifiedRefs = results.reduce((n, r) => n + r.findings.filter((f) => f.severity === "unverified").length, 0);
13364
13579
  return jsonResponse({
@@ -13372,6 +13587,8 @@ function registerValidatorTools(server2, client, builderClient) {
13372
13587
  errors_total: errorsTotal,
13373
13588
  workflows_with_merge_field_warnings: withWarnings.length,
13374
13589
  merge_field_warnings_total: warningsTotal,
13590
+ workflows_with_shape_warnings: withShapeWarnings.length,
13591
+ shape_warnings_total: shapeWarningsTotal,
13375
13592
  workflows_unscannable: unscannable.length,
13376
13593
  workflows_zero_references: zeroRefCount,
13377
13594
  unverified: { categories_unloaded: unverifiedCats, references_unverified: unverifiedRefs },
@@ -13379,12 +13596,14 @@ function registerValidatorTools(server2, client, builderClient) {
13379
13596
  },
13380
13597
  workflows_with_issues: withErrors,
13381
13598
  merge_field_warnings: withWarnings,
13599
+ shape_warnings: withShapeWarnings,
13382
13600
  unscannable,
13383
13601
  notes: [
13384
13602
  ...catalog.complete ? [] : ["Workflow catalog exceeded the pagination backstop \u2014 some workflow-id references shown as unverified."],
13385
13603
  ...catalog.rows.length > SCAN_CAP ? [`Only the first ${SCAN_CAP} workflows were scanned (account has ${catalog.rows.length}).`] : [],
13386
13604
  ...unverifiedCats.length ? [`Could not fully load: ${unverifiedCats.join(", ")} \u2014 references to those are 'unverified', not 'broken'. Re-run.`] : [],
13387
13605
  ...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.`] : [],
13606
+ ...shapeWarningsTotal ? [`${shapeWarningsTotal} Create/Update Opportunity step(s) have only pipeline + stage \u2014 they cannot create a card for a new contact (see shape_warnings).`] : [],
13388
13607
  "workflow_goal, goto, and unrecognized condition types are not deeply checked in this version."
13389
13608
  ]
13390
13609
  });
@@ -13394,7 +13613,7 @@ function registerValidatorTools(server2, client, builderClient) {
13394
13613
  }
13395
13614
  );
13396
13615
  }
13397
- var import_zod53, ALL_CATEGORIES, STANDARD_CONTACT_FIELDS, MERGE_TAG_RE;
13616
+ var import_zod53, ALL_CATEGORIES, OPPORTUNITY_CONTEXT_TRIGGERS, OPPORTUNITY_CREATING_TYPES, KNOWN_UPDATE_OPP_FIELDS, MAX_TRACE_HOPS, STANDARD_CONTACT_FIELDS, MERGE_TAG_RE;
13398
13617
  var init_validators = __esm({
13399
13618
  "src/tools/validators.ts"() {
13400
13619
  "use strict";
@@ -13403,6 +13622,16 @@ var init_validators = __esm({
13403
13622
  init_api_schemas();
13404
13623
  init_id_shape();
13405
13624
  ALL_CATEGORIES = ["pipeline", "stage", "custom_field", "user", "workflow", "form", "calendar", "survey"];
13625
+ OPPORTUNITY_CONTEXT_TRIGGERS = /* @__PURE__ */ new Set([
13626
+ "opportunity_status_changed",
13627
+ "opportunity_created",
13628
+ "opportunity_changed",
13629
+ "pipeline_stage_updated",
13630
+ "opportunity_decay"
13631
+ ]);
13632
+ OPPORTUNITY_CREATING_TYPES = /* @__PURE__ */ new Set(["internal_create_opportunity", "create_opportunity"]);
13633
+ KNOWN_UPDATE_OPP_FIELDS = /* @__PURE__ */ new Set(["pipelineId", "pipelineStageId", "name", "status", "source", "monetaryValue"]);
13634
+ MAX_TRACE_HOPS = 200;
13406
13635
  STANDARD_CONTACT_FIELDS = /* @__PURE__ */ new Set([
13407
13636
  "first_name",
13408
13637
  "firstname",
@@ -18799,9 +19028,9 @@ var require_package = __commonJS({
18799
19028
  "package.json"(exports2, module2) {
18800
19029
  module2.exports = {
18801
19030
  name: "@elitedcs/ghl-mcp",
18802
- version: "3.69.0",
19031
+ version: "3.71.0",
18803
19032
  mcpName: "io.github.drjerryrelth/ghl-command",
18804
- description: "GoHighLevel MCP Server for Claude. 242 tools \u2014 full CRM, automation, marketing control, account-wide workflow audit, live funnel-capture verification, and the only programmatic GHL workflow builder, now multi-tenant across client accounts.",
19033
+ description: "GoHighLevel MCP Server for Claude. 245 tools \u2014 full CRM, automation, marketing control, account-wide workflow audit, live funnel-capture verification, and the only programmatic GHL workflow builder, now multi-tenant across client accounts.",
18805
19034
  main: "dist/index.js",
18806
19035
  bin: {
18807
19036
  "ghl-mcp": "dist/index.js"
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "@elitedcs/ghl-mcp",
3
- "version": "3.69.0",
3
+ "version": "3.71.0",
4
4
  "mcpName": "io.github.drjerryrelth/ghl-command",
5
- "description": "GoHighLevel MCP Server for Claude. 242 tools \u2014 full CRM, automation, marketing control, account-wide workflow audit, live funnel-capture verification, and the only programmatic GHL workflow builder, now multi-tenant across client accounts.",
5
+ "description": "GoHighLevel MCP Server for Claude. 245 tools \u2014 full CRM, automation, marketing control, account-wide workflow audit, live funnel-capture verification, and the only programmatic GHL workflow builder, now multi-tenant across client accounts.",
6
6
  "main": "dist/index.js",
7
7
  "bin": {
8
8
  "ghl-mcp": "dist/index.js"
@@ -106,7 +106,7 @@
106
106
  "selectedUser": "USER_ID"
107
107
  }
108
108
  },
109
- "notes": "Nested 'notification' object REQUIRED. selectedUser MUST be a real user ID GHL now REJECTS an empty string (live-verified 2026-08-06; the old 'empty = all users' behavior is gone). Use get_users to find IDs. EMAIL CHANNEL (verified live 2026-07-20, PWDJ workflow e91f28da): attributes.type is 'email' (NOT 'notification'), nested key is 'email' (NOT 'notification'), body field is 'html' (NOT 'body'), selectedUser is an ARRAY of user IDs, include attachments:[] and isCloned:false. A 'send_email' discriminator inside a 'notification' object saves but silently never sends.",
109
+ "notes": "Nested 'notification' object REQUIRED. selectedUser MUST be a real user ID \u2014 GHL now REJECTS an empty string (live-verified 2026-08-06; the old 'empty = all users' behavior is gone). Use get_users to find IDs. EMAIL CHANNEL (verified live 2026-07-20, PWDJ workflow e91f28da): attributes.type is 'email' (NOT 'notification'), nested key is 'email' (NOT 'notification'), body field is 'html' (NOT 'body'), selectedUser is an ARRAY of user IDs, include attachments:[] and isCloned:false. A 'send_email' discriminator inside a 'notification' object saves but silently never sends.",
110
110
  "emailChannelExample": {
111
111
  "type": "email",
112
112
  "email": {
@@ -166,6 +166,27 @@
166
166
  },
167
167
  "notes": "Requires BOTH workflowId (string) AND workflow_id (ARRAY with same ID). Also needs type: 'remove_from_workflow' inside attributes. Derived from GHL UI."
168
168
  },
169
+ "internal_create_opportunity": {
170
+ "example": {
171
+ "type": "internal_create_opportunity",
172
+ "workflowsActionType": "INTERNAL",
173
+ "attributes": {
174
+ "type": "internal_create_opportunity",
175
+ "pipelineId": "PIPELINE_ID",
176
+ "__customInputFields__": [
177
+ {
178
+ "__customInputs__": {},
179
+ "dataType": "SINGLE_OPTIONS",
180
+ "filterField": "pipelineStageId",
181
+ "value": "STAGE_ID",
182
+ "valueFieldType": "select"
183
+ }
184
+ ],
185
+ "__customInputs__": {}
186
+ }
187
+ },
188
+ "notes": "CREATES a card (GHL's newer separate 'Create Opportunity' action). This minimal shape is live-proven to create at runtime (2026-07-24): pipelineId at the ATTRIBUTES level (not inside __customInputFields__), __customInputFields__ carrying pipelineStageId, workflowsActionType:'INTERNAL' at the NODE level. UI-built nodes also carry name/status/monetaryValue entries and work \u2014 copy a working node field-for-field rather than inventing entries. Without a name entry the card is named from the contact (observed live 2026-07-24). Use THIS for 'create an opportunity for the new lead'."
189
+ },
169
190
  "internal_update_opportunity": {
170
191
  "example": {
171
192
  "__customInputFields__": [
@@ -185,7 +206,7 @@
185
206
  "workflowsActionType": "INTERNAL",
186
207
  "type": "internal_update_opportunity"
187
208
  },
188
- "notes": "CREATABLE from scratch (re-enabled v3.41.0). The discriminator workflowsActionType:'INTERNAL' MUST sit at the NODE level, never nested in attributes \u2014 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) \u2014 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."
209
+ "notes": "GHL's combined 'Create/Update Opportunity'. With only pipelineId + pipelineStageId it CANNOT create a card \u2014 it moves one the contact already has; a contact with no opportunity (a new lead) gets nothing and the workflow continues as if it worked (customer report 2026-08-26). It creates only when __customInputFields__ also carry Opportunity name (GHL's doc: Name, Source, Status mandatory to create). GoHighLevel is phasing this combined action out for new workflows, so prefer internal_create_opportunity to create; use this node after find_opportunity, after a create step in the same workflow, or on an opportunity trigger. CREATABLE from scratch (re-enabled v3.41.0). The discriminator workflowsActionType:'INTERNAL' MUST sit at the NODE level, never nested in attributes \u2014 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) \u2014 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."
189
210
  },
190
211
  "_if_else_branching": {
191
212
  "notes": "if_else is a node type discriminator only. Do not send a single flat if_else action.",