@elitedcs/ghl-mcp 3.31.0 → 3.33.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,67 @@
1
1
  # Changelog
2
2
 
3
+ ## 3.33.0 — Account-wide silent-failure audit + trust hardening
4
+
5
+ **`audit_workflows`** — scans EVERY workflow in the current location for
6
+ references to pipelines, stages, custom fields, users, workflows, forms,
7
+ calendars, and surveys that don't exist — the GHL bug where one bad ID silently
8
+ kills that action and every action after it. Returns a prioritized report:
9
+ what's broken, what couldn't be scanned, what couldn't be fully verified.
10
+ Conservative by construction: it never reports a false break (uncertain checks
11
+ are `unverified`, not `error`). The same engine upgrade sharpens
12
+ `validate_workflow`: four opportunity action shapes (including the dominant
13
+ UI-native `internal_create_opportunity`), if/else condition-node custom-field
14
+ checks, `create_update_contact` field refs, hyphenated `task-notification`
15
+ nodes, and custom-field trigger conditions are now covered. 18 new unit tests;
16
+ verified live against 35 real workflows with zero false alarms.
17
+
18
+ **`register_agency_key`** — new tool to store the agency-level (company-scoped)
19
+ API key, with live validation before saving. Previously the snapshot tools
20
+ required an agency key that no tool could register.
21
+
22
+ **Trust + reliability hardening** (from the 2026-06-10 audit):
23
+
24
+ - List tools now attach `_pagination` (returned / limit / total / complete +
25
+ an explicit INCOMPLETE note) so a single page can never silently read as the
26
+ full dataset: `search_contacts`, `search_opportunities`,
27
+ `search_conversations`, `list_invoices`, `list_workflows_full`.
28
+ - Version is read from package.json at runtime instead of baked at build time —
29
+ a stale local build can no longer misreport its version.
30
+ - `health_check` now reports a corrupted token registry as a FAIL with recovery
31
+ steps (previously only visible on stderr, i.e. invisible in the Desktop App).
32
+ - Clearer guidance errors: missing locationId now points at `switch_location` /
33
+ `list_registered_locations`; missing agency key points at
34
+ `register_agency_key`.
35
+ - Firebase token-refresh URL now percent-encodes the API key (consistency).
36
+ - New regression tests pin that error messages never contain API keys or
37
+ Firebase refresh tokens.
38
+
39
+ ## 3.32.0 — Account-health summary + phone reads
40
+
41
+ Three read tools. The composite is the second roadmap build and the first
42
+ "how's the account doing" answer (GHL has no public reporting API, confirmed by
43
+ probe — so this composes existing reads).
44
+
45
+ - **`get_account_health_summary`** — one call returns, for a location: total
46
+ contacts + NEW contacts in a window (default 30d), total opportunities + counts
47
+ by status (open/won/lost/abandoned), total conversations, and phone-number
48
+ count.
49
+ - **`list_phone_numbers`** — provisioned LC Phone numbers (sid, number, label).
50
+ - **`list_number_pools`** — configured number pools.
51
+
52
+ **Honesty by construction.** Every metric is explicitly labeled `scope`
53
+ (`all_time` vs `window`, with start/end on windowed ones) so an all-time number
54
+ can never be read as a recent one. Any metric that can't be read returns
55
+ `{status:"unavailable", reason}` — never a misleading `0`. Each sub-read is
56
+ isolated, so one failure degrades only its own section, not the whole summary.
57
+
58
+ **Scope (verified against the live API):** windowed *new contacts* use the
59
+ `/contacts/search` `dateAdded` range filter; opportunity status counts use
60
+ filtered `meta.total`. Conversations are all-time only (the API's
61
+ `startAfterDate` is a cursor, not a count filter). Revenue (transactions are
62
+ 403 for sub-account tokens) and appointments (no location-wide events endpoint)
63
+ are intentionally excluded.
64
+
3
65
  ## 3.31.0 — Snapshots: list + share-link (agency tooling)
4
66
 
5
67
  Two new agency-level tools, the first build toward removing manual steps from the
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # GHL Command — GoHighLevel MCP Server
2
2
 
3
- **Full GoHighLevel API access for Claude.** 212 tools across 43 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.** 219 tools across 43 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
 
@@ -116,7 +116,7 @@ Run setup_ghl_mcp to activate GHL Command:
116
116
  ghl_location_id: YOUR_LOCATION_ID
117
117
  ```
118
118
 
119
- 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** — all 163 core tools are now unlocked (212 with the optional Workflow Builder Firebase add-on).
119
+ 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 (219 tools total with the optional Workflow Builder Firebase add-on).
120
120
 
121
121
  ### 4. Try it
122
122
 
@@ -537,7 +537,7 @@ To unlock full builder access across multiple clients from one install:
537
537
  Uses **esbuild** for production builds (not tsc). TypeScript 5.9.3 + MCP SDK types cause tsc to run out of memory at 4GB+. esbuild bundles in ~5ms with zero memory issues.
538
538
 
539
539
  ```bash
540
- npm run build # esbuild → dist/index.js (~212KB)
540
+ npm run build # esbuild → dist/index.js (~428KB)
541
541
  npm run dev # tsc --watch (type-checking only)
542
542
  ```
543
543
 
@@ -716,7 +716,7 @@ Source repo is private. Contributors need an invitation from `drjerryrelth`. The
716
716
 
717
717
  ### Reducing context / token usage
718
718
 
719
- Every registered MCP tool's schema is shipped to the model on every message. With 212 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`:
719
+ Every registered MCP tool's schema is shipped to the model on every message. With 219 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`:
720
720
 
721
721
  ```jsonc
722
722
  // Claude Desktop config — enable whole modules
package/dist/index.js CHANGED
@@ -31,9 +31,9 @@ var require_package = __commonJS({
31
31
  "package.json"(exports2, module2) {
32
32
  module2.exports = {
33
33
  name: "@elitedcs/ghl-mcp",
34
- version: "3.31.0",
34
+ version: "3.33.0",
35
35
  mcpName: "io.github.drjerryrelth/ghl-command",
36
- description: "GoHighLevel MCP Server for Claude. 214 tools \u2014 full CRM, automation, marketing control, and the only programmatic GHL workflow builder, now multi-tenant across client accounts.",
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",
38
38
  bin: {
39
39
  "ghl-mcp": "dist/index.js"
@@ -262,7 +262,7 @@ ${errorBody}`
262
262
  const id = providedId || this.defaultLocationId;
263
263
  if (!id) {
264
264
  throw new Error(
265
- "locationId is required. Provide it as a parameter or set GHL_LOCATION_ID in your .env file."
265
+ "locationId is required. Provide it as a parameter, run switch_location to pick a registered sub-account (list_registered_locations shows what's registered), or set GHL_LOCATION_ID in your .env file."
266
266
  );
267
267
  }
268
268
  return id;
@@ -406,6 +406,7 @@ var TokenRegistryDataSchema = import_zod2.z.object({
406
406
  });
407
407
  var TokenRegistry = class _TokenRegistry {
408
408
  data;
409
+ loadFailure = null;
409
410
  filePath;
410
411
  constructor(filePath) {
411
412
  if (filePath) {
@@ -454,18 +455,28 @@ var TokenRegistry = class _TokenRegistry {
454
455
  return TokenRegistryDataSchema.parse(JSON.parse(raw));
455
456
  }
456
457
  } catch (error) {
458
+ let backupNote = "backup also failed \u2014 the corrupted file is still at " + this.filePath;
457
459
  try {
458
460
  const backupPath = this.filePath + ".corrupted." + Date.now();
459
461
  fs2.copyFileSync(this.filePath, backupPath);
462
+ backupNote = `backed up to ${backupPath}`;
460
463
  process.stderr.write(`[ghl-mcp] ERROR: Token registry corrupted. Backed up to ${backupPath}
461
464
  `);
462
465
  } catch {
463
466
  }
467
+ this.loadFailure = `Token registry could not be loaded (${backupNote}). All sub-account registrations are unavailable this session \u2014 re-register via register_location, or restore the backup over ${this.filePath} and restart.`;
464
468
  process.stderr.write(`[ghl-mcp] Warning: Could not load token registry: ${error}
465
469
  `);
466
470
  }
467
471
  return { tokens: {} };
468
472
  }
473
+ /**
474
+ * Non-null when the registry file existed but could not be parsed at
475
+ * startup (we fell back to an empty registry). Surfaced by health_check.
476
+ */
477
+ getLoadFailure() {
478
+ return this.loadFailure;
479
+ }
469
480
  save() {
470
481
  const tmpPath = `${this.filePath}.tmp.${process.pid}.${(0, import_crypto.randomBytes)(8).toString("hex")}`;
471
482
  try {
@@ -503,6 +514,13 @@ var TokenRegistry = class _TokenRegistry {
503
514
  getAgencyKey() {
504
515
  return this.data.agencyKey;
505
516
  }
517
+ /**
518
+ * Store the agency/company-scoped API key (snapshots, agency-wide reads).
519
+ */
520
+ setAgencyKey(key) {
521
+ this.data.agencyKey = key;
522
+ this.save();
523
+ }
506
524
  /**
507
525
  * Get Firebase config
508
526
  */
@@ -1412,7 +1430,7 @@ var WorkflowBuilderClient = class _WorkflowBuilderClient {
1412
1430
  }
1413
1431
  }
1414
1432
  async performTokenRefresh() {
1415
- const url = `${FIREBASE_TOKEN_URL}?key=${this.firebaseApiKey}`;
1433
+ const url = `${FIREBASE_TOKEN_URL}?key=${encodeURIComponent(this.firebaseApiKey)}`;
1416
1434
  const body = `grant_type=refresh_token&refresh_token=${encodeURIComponent(this.refreshToken)}`;
1417
1435
  const response = await fetch(url, {
1418
1436
  method: "POST",
@@ -1924,6 +1942,48 @@ function escapeRegex(str) {
1924
1942
  function errorMessage(error) {
1925
1943
  return error instanceof Error ? error.message : String(error);
1926
1944
  }
1945
+ function paginationInfo(opts) {
1946
+ const { returned, limit, nextHint } = opts;
1947
+ const total = typeof opts.total === "number" ? opts.total : void 0;
1948
+ if (total !== void 0) {
1949
+ const complete = returned >= total;
1950
+ return {
1951
+ returned,
1952
+ limit,
1953
+ total,
1954
+ complete,
1955
+ ...complete ? {} : {
1956
+ note: `INCOMPLETE: showing ${returned} of ${total} total. ${nextHint ?? "Fetch further pages before treating this as the full list."}`
1957
+ }
1958
+ };
1959
+ }
1960
+ if (returned < limit) {
1961
+ return { returned, limit, complete: true };
1962
+ }
1963
+ return {
1964
+ returned,
1965
+ limit,
1966
+ complete: "unknown",
1967
+ note: `POSSIBLY INCOMPLETE: returned ${returned} which hit the limit of ${limit} \u2014 more may exist. ${nextHint ?? "Fetch further pages before treating this as the full list."}`
1968
+ };
1969
+ }
1970
+ function annotateListResponse(raw, listKey, limit, nextHint) {
1971
+ if (typeof raw !== "object" || raw === null) return raw;
1972
+ const obj = raw;
1973
+ const list = obj[listKey];
1974
+ if (!Array.isArray(list)) return raw;
1975
+ let total;
1976
+ const meta = obj.meta;
1977
+ if (typeof meta === "object" && meta !== null) {
1978
+ const metaTotal = meta.total;
1979
+ if (typeof metaTotal === "number") total = metaTotal;
1980
+ }
1981
+ if (total === void 0 && typeof obj.total === "number") total = obj.total;
1982
+ return {
1983
+ ...obj,
1984
+ _pagination: paginationInfo({ returned: list.length, limit, total, nextHint })
1985
+ };
1986
+ }
1927
1987
  function safeTool(server2, name, description, schema, handler) {
1928
1988
  server2.tool(name, description, schema, async (args) => {
1929
1989
  try {
@@ -2017,7 +2077,16 @@ function registerContactTools(server2, client) {
2017
2077
  order
2018
2078
  }
2019
2079
  });
2020
- return ContactSearchResponseSchema.parse(raw);
2080
+ const parsed = ContactSearchResponseSchema.parse(raw);
2081
+ return {
2082
+ ...parsed,
2083
+ _pagination: paginationInfo({
2084
+ returned: parsed.contacts.length,
2085
+ limit: limit ?? 20,
2086
+ total: parsed.meta?.total,
2087
+ nextHint: "Pass startAfter + startAfterId from this response's meta to fetch the next page (both are required to advance)."
2088
+ })
2089
+ };
2021
2090
  }
2022
2091
  );
2023
2092
  safeTool(
@@ -2308,7 +2377,7 @@ function registerConversationTools(server2, client) {
2308
2377
  },
2309
2378
  async ({ locationId: locationId2, contactId, assignedTo, query, status, limit, startAfterDate }) => {
2310
2379
  const resolvedLocationId = client.resolveLocationId(locationId2);
2311
- return await client.get("/conversations/search", {
2380
+ const raw = await client.get("/conversations/search", {
2312
2381
  params: {
2313
2382
  locationId: resolvedLocationId,
2314
2383
  contactId,
@@ -2319,6 +2388,12 @@ function registerConversationTools(server2, client) {
2319
2388
  startAfterDate
2320
2389
  }
2321
2390
  });
2391
+ return annotateListResponse(
2392
+ raw,
2393
+ "conversations",
2394
+ limit ?? 20,
2395
+ "Pass startAfterDate from the last conversation's dateUpdated to fetch the next page."
2396
+ );
2322
2397
  }
2323
2398
  );
2324
2399
  safeTool(
@@ -2463,7 +2538,7 @@ function registerOpportunityTools(server2, client) {
2463
2538
  },
2464
2539
  async (args) => {
2465
2540
  const locationId2 = client.resolveLocationId(args.locationId);
2466
- return await client.get("/opportunities/search", {
2541
+ const raw = await client.get("/opportunities/search", {
2467
2542
  params: {
2468
2543
  location_id: locationId2,
2469
2544
  pipeline_id: args.pipelineId,
@@ -2480,6 +2555,12 @@ function registerOpportunityTools(server2, client) {
2480
2555
  startDate: args.startDate
2481
2556
  }
2482
2557
  });
2558
+ return annotateListResponse(
2559
+ raw,
2560
+ "opportunities",
2561
+ args.limit ?? 20,
2562
+ "Pass startAfter + startAfterId from this response's meta to fetch the next page."
2563
+ );
2483
2564
  }
2484
2565
  );
2485
2566
  safeTool(
@@ -3581,7 +3662,13 @@ function registerInvoiceTools(server2, client) {
3581
3662
  if (startAt !== void 0) params.startAt = startAt;
3582
3663
  if (endAt !== void 0) params.endAt = endAt;
3583
3664
  if (search !== void 0) params.search = search;
3584
- return client.get("/invoices/", { params });
3665
+ const raw = await client.get("/invoices/", { params });
3666
+ return annotateListResponse(
3667
+ raw,
3668
+ "invoices",
3669
+ limit ?? 10,
3670
+ "Pass offset (current offset + limit) to fetch the next page."
3671
+ );
3585
3672
  }
3586
3673
  );
3587
3674
  safeTool(
@@ -3617,8 +3704,8 @@ function registerInvoiceTools(server2, client) {
3617
3704
  currency: import_zod17.z.string().describe("Currency code (e.g. USD)")
3618
3705
  })).describe("Invoice line items"),
3619
3706
  discount: import_zod17.z.object({
3620
- type: import_zod17.z.string().optional(),
3621
- value: import_zod17.z.number().optional()
3707
+ type: import_zod17.z.string().optional().describe("Discount type: 'percentage' or 'fixed'."),
3708
+ value: import_zod17.z.number().optional().describe("Discount amount: percent (0-100) when type=percentage, currency amount when type=fixed.")
3622
3709
  }).optional().describe("Discount to apply"),
3623
3710
  termsNotes: import_zod17.z.string().optional().describe("Terms and notes for the invoice"),
3624
3711
  title: import_zod17.z.string().optional().describe("Invoice title")
@@ -3654,8 +3741,8 @@ function registerInvoiceTools(server2, client) {
3654
3741
  currency: import_zod17.z.string().describe("Currency code (e.g. USD)")
3655
3742
  })).optional().describe("Invoice line items"),
3656
3743
  discount: import_zod17.z.object({
3657
- type: import_zod17.z.string().optional(),
3658
- value: import_zod17.z.number().optional()
3744
+ type: import_zod17.z.string().optional().describe("Discount type: 'percentage' or 'fixed'."),
3745
+ value: import_zod17.z.number().optional().describe("Discount amount: percent (0-100) when type=percentage, currency amount when type=fixed.")
3659
3746
  }).optional().describe("Discount to apply"),
3660
3747
  termsNotes: import_zod17.z.string().optional().describe("Terms and notes for the invoice"),
3661
3748
  title: import_zod17.z.string().optional().describe("Invoice title")
@@ -5212,7 +5299,10 @@ function registerWorkflowBuilderTools(server2, client) {
5212
5299
  async ({ limit, skip }) => {
5213
5300
  try {
5214
5301
  const result = await client.listWorkflows(limit ?? 50, skip ?? 0);
5215
- return jsonResponse(result);
5302
+ const hint = "Pass skip (current skip + limit) to fetch the next page.";
5303
+ let annotated = annotateListResponse(result, "rows", limit ?? 50, hint);
5304
+ if (annotated === result) annotated = annotateListResponse(result, "workflows", limit ?? 50, hint);
5305
+ return jsonResponse(annotated);
5216
5306
  } catch (error) {
5217
5307
  return errorResponse(error);
5218
5308
  }
@@ -6822,6 +6912,60 @@ The API key could not access location ${locationId2}. Make sure:
6822
6912
  }
6823
6913
  }
6824
6914
  );
6915
+ server2.tool(
6916
+ "register_agency_key",
6917
+ "Store the AGENCY-level (company-scoped) API key in the token registry. This key powers agency-wide tools: list_snapshots, create_snapshot_share_link, and list_available_locations across all sub-accounts. Create it at the AGENCY level in GHL (Agency Settings > Private Integrations) \u2014 it is different from a sub-account's key. The key is validated before saving.",
6918
+ {
6919
+ apiKey: import_zod38.z.string().describe("The agency-level Private Integration API key (starts with 'pit-'). Must be created in AGENCY settings, not inside a sub-account.")
6920
+ },
6921
+ async ({ apiKey: apiKey2 }) => {
6922
+ if (!registry2) {
6923
+ return {
6924
+ content: [{ type: "text", text: "Token registry not available. Check .ghl-tokens.json file." }],
6925
+ isError: true
6926
+ };
6927
+ }
6928
+ const testClient = new GHLClient({ apiKey: apiKey2 });
6929
+ try {
6930
+ const probe = await testClient.get("/locations/search", { params: { limit: 1, skip: 0 } });
6931
+ const locations = probe?.locations;
6932
+ if (!Array.isArray(locations)) {
6933
+ throw new Error(
6934
+ "the key was accepted by GHL but the response is not an agency location-search envelope (no locations array) \u2014 refusing to store it as the agency key"
6935
+ );
6936
+ }
6937
+ } catch (error) {
6938
+ const message = error instanceof Error ? error.message : String(error);
6939
+ return {
6940
+ content: [
6941
+ {
6942
+ type: "text",
6943
+ text: `Failed to validate the agency key: ${message}
6944
+
6945
+ Make sure:
6946
+ 1. The Private Integration was created at the AGENCY level (Agency Settings > Private Integrations), not inside a sub-account
6947
+ 2. The key is correct (it can only be copied once when created)
6948
+ 3. The integration has the locations scope enabled`
6949
+ }
6950
+ ],
6951
+ isError: true
6952
+ };
6953
+ }
6954
+ const hadKey = Boolean(registry2.getAgencyKey());
6955
+ registry2.setAgencyKey(apiKey2);
6956
+ return {
6957
+ content: [
6958
+ {
6959
+ type: "text",
6960
+ text: `Agency key ${hadKey ? "replaced" : "registered"}: ${apiKey2.substring(0, 12)}...
6961
+ Saved to the token registry.
6962
+
6963
+ Agency-wide tools now available: list_snapshots, create_snapshot_share_link, and list_available_locations (across all sub-accounts).`
6964
+ }
6965
+ ]
6966
+ };
6967
+ }
6968
+ );
6825
6969
  server2.tool(
6826
6970
  "unregister_location",
6827
6971
  "Remove a GHL sub-account from the token registry.",
@@ -8345,6 +8489,11 @@ ${errors.join("\n")}` : "\nNo errors!",
8345
8489
 
8346
8490
  // src/tools/validators.ts
8347
8491
  var import_zod47 = require("zod");
8492
+ var ALL_CATEGORIES = ["pipeline", "stage", "custom_field", "user", "workflow", "form", "calendar", "survey"];
8493
+ var ID_SHAPE = /^[A-Za-z0-9]{17,}$/;
8494
+ function isIdShaped(s) {
8495
+ return typeof s === "string" && ID_SHAPE.test(s);
8496
+ }
8348
8497
  function extractFromTrigger(trigger, refs) {
8349
8498
  const triggerName = String(trigger.name ?? trigger.type ?? "unnamed trigger");
8350
8499
  const where = `trigger "${triggerName}"`;
@@ -8354,10 +8503,21 @@ function extractFromTrigger(trigger, refs) {
8354
8503
  const field = typeof c.field === "string" ? c.field : null;
8355
8504
  const value = c.value;
8356
8505
  if (!field || value === void 0 || value === null) continue;
8506
+ const childWhere = `${where} \u2192 conditions[${i}] (field=${field})`;
8507
+ if (field.startsWith("contact.")) {
8508
+ const suffix = field.slice("contact.".length);
8509
+ if (suffix === "assignedTo") {
8510
+ for (const v of Array.isArray(value) ? value : [value]) {
8511
+ if (isIdShaped(v)) refs.push({ kind: "user", id: v, where: childWhere });
8512
+ }
8513
+ } else if (isIdShaped(suffix)) {
8514
+ refs.push({ kind: "custom_field", id: suffix, where: childWhere });
8515
+ }
8516
+ continue;
8517
+ }
8357
8518
  const valueAsArray = Array.isArray(value) ? value : [value];
8358
8519
  for (const v of valueAsArray) {
8359
8520
  if (typeof v !== "string") continue;
8360
- const childWhere = `${where} \u2192 conditions[${i}] (field=${field})`;
8361
8521
  switch (field) {
8362
8522
  case "opportunity.pipelineId":
8363
8523
  refs.push({ kind: "pipeline", id: v, where: childWhere });
@@ -8366,7 +8526,6 @@ function extractFromTrigger(trigger, refs) {
8366
8526
  refs.push({ kind: "stage", id: v, where: childWhere });
8367
8527
  break;
8368
8528
  case "opportunity.assignedTo":
8369
- case "contact.assignedTo":
8370
8529
  refs.push({ kind: "user", id: v, where: childWhere });
8371
8530
  break;
8372
8531
  case "workflow.id":
@@ -8384,17 +8543,18 @@ function extractFromTrigger(trigger, refs) {
8384
8543
  }
8385
8544
  }
8386
8545
  }
8387
- const triggerActions = Array.isArray(trigger.actions) ? trigger.actions : [];
8388
- for (let i = 0; i < triggerActions.length; i++) {
8389
- const a = triggerActions[i];
8390
- if (a.type === "add_to_workflow" && typeof a.workflow_id === "string") {
8391
- refs.push({
8392
- kind: "workflow",
8393
- id: a.workflow_id,
8394
- where: `${where} \u2192 actions[${i}] (add_to_workflow)`
8395
- });
8546
+ }
8547
+ function customInputId(attr, filterFields) {
8548
+ const arr = Array.isArray(attr.__customInputFields__) ? attr.__customInputFields__ : [];
8549
+ for (const raw of arr) {
8550
+ if (typeof raw !== "object" || raw === null) continue;
8551
+ const c = raw;
8552
+ if (typeof c.filterField === "string" && filterFields.includes(c.filterField)) {
8553
+ if (isIdShaped(c.secondValue)) return c.secondValue;
8554
+ if (isIdShaped(c.value)) return c.value;
8396
8555
  }
8397
8556
  }
8557
+ return void 0;
8398
8558
  }
8399
8559
  function extractFromAction(action, refs) {
8400
8560
  const type = typeof action.type === "string" ? action.type : "unknown";
@@ -8402,258 +8562,267 @@ function extractFromAction(action, refs) {
8402
8562
  const where = `action "${name}" (${type})`;
8403
8563
  const attr = action.attributes ?? {};
8404
8564
  switch (type) {
8405
- case "update_contact_field": {
8565
+ // ── Contact custom fields (only id-shaped values; standard names skipped) ──
8566
+ case "update_contact_field":
8567
+ case "create_update_contact": {
8406
8568
  const fields = Array.isArray(attr.fields) ? attr.fields : [];
8407
8569
  for (let i = 0; i < fields.length; i++) {
8408
8570
  const f = fields[i];
8409
- if (typeof f.field === "string") {
8410
- refs.push({
8411
- kind: "custom_field",
8412
- id: f.field,
8413
- where: `${where} \u2192 fields[${i}]`
8414
- });
8415
- }
8571
+ if (isIdShaped(f?.field)) refs.push({ kind: "custom_field", id: f.field, where: `${where} \u2192 fields[${i}]` });
8416
8572
  }
8417
8573
  break;
8418
8574
  }
8575
+ // ── User refs ──
8419
8576
  case "internal_notification": {
8420
8577
  const notif = attr.notification ?? {};
8421
- if (typeof notif.selectedUser === "string" && notif.selectedUser.trim() !== "") {
8422
- refs.push({
8423
- kind: "user",
8424
- id: notif.selectedUser,
8425
- where: `${where} \u2192 notification.selectedUser`
8426
- });
8578
+ if (isIdShaped(notif.selectedUser))
8579
+ refs.push({ kind: "user", id: notif.selectedUser, where: `${where} \u2192 notification.selectedUser` });
8580
+ const sms = attr.sms ?? {};
8581
+ const smsUsers = Array.isArray(sms.selectedUser) ? sms.selectedUser : [];
8582
+ for (let i = 0; i < smsUsers.length; i++) {
8583
+ if (isIdShaped(smsUsers[i])) refs.push({ kind: "user", id: smsUsers[i], where: `${where} \u2192 sms.selectedUser[${i}]` });
8427
8584
  }
8428
8585
  break;
8429
8586
  }
8430
- case "task_notification": {
8431
- if (typeof attr.assignedTo === "string" && attr.assignedTo.trim() !== "") {
8432
- refs.push({
8433
- kind: "user",
8434
- id: attr.assignedTo,
8435
- where: `${where} \u2192 assignedTo`
8436
- });
8437
- }
8587
+ case "task_notification":
8588
+ case "task-notification": {
8589
+ if (isIdShaped(attr.assignedTo))
8590
+ refs.push({ kind: "user", id: attr.assignedTo, where: `${where} \u2192 assignedTo` });
8438
8591
  break;
8439
8592
  }
8593
+ // ── Workflow refs ──
8440
8594
  case "remove_from_workflow": {
8441
- if (typeof attr.workflowId === "string") {
8442
- refs.push({
8443
- kind: "workflow",
8444
- id: attr.workflowId,
8445
- where: `${where} \u2192 workflowId`
8446
- });
8447
- }
8448
- const workflowIdArr = Array.isArray(attr.workflow_id) ? attr.workflow_id : [];
8449
- for (let i = 0; i < workflowIdArr.length; i++) {
8450
- const v = workflowIdArr[i];
8451
- if (typeof v === "string") {
8452
- refs.push({
8453
- kind: "workflow",
8454
- id: v,
8455
- where: `${where} \u2192 workflow_id[${i}]`
8456
- });
8457
- }
8458
- }
8595
+ if (typeof attr.workflowId === "string") refs.push({ kind: "workflow", id: attr.workflowId, where: `${where} \u2192 workflowId` });
8596
+ const arr = Array.isArray(attr.workflow_id) ? attr.workflow_id : [];
8597
+ for (let i = 0; i < arr.length; i++) if (typeof arr[i] === "string") refs.push({ kind: "workflow", id: arr[i], where: `${where} \u2192 workflow_id[${i}]` });
8598
+ break;
8599
+ }
8600
+ // ── Opportunity refs — FOUR distinct shapes ──
8601
+ case "internal_create_opportunity": {
8602
+ if (isIdShaped(attr.pipelineId)) refs.push({ kind: "pipeline", id: attr.pipelineId, where: `${where} \u2192 pipelineId` });
8603
+ const stage = customInputId(attr, ["pipelineStageId"]);
8604
+ if (stage) refs.push({ kind: "stage", id: stage, where: `${where} \u2192 __customInputFields__.pipelineStageId` });
8605
+ break;
8606
+ }
8607
+ case "create_opportunity": {
8608
+ if (isIdShaped(attr.pipeline_id)) refs.push({ kind: "pipeline", id: attr.pipeline_id, where: `${where} \u2192 pipeline_id` });
8609
+ if (isIdShaped(attr.pipeline_stage_id)) refs.push({ kind: "stage", id: attr.pipeline_stage_id, where: `${where} \u2192 pipeline_stage_id` });
8459
8610
  break;
8460
8611
  }
8461
8612
  case "internal_update_opportunity": {
8462
- const customInputs = Array.isArray(attr.__customInputFields__) ? attr.__customInputFields__ : [];
8463
- for (let i = 0; i < customInputs.length; i++) {
8464
- const c = customInputs[i];
8465
- if (typeof c.value !== "string") continue;
8466
- if (c.filterField === "pipelineId") {
8467
- refs.push({ kind: "pipeline", id: c.value, where: `${where} \u2192 __customInputFields__[${i}].pipelineId` });
8468
- } else if (c.filterField === "pipelineStageId") {
8469
- refs.push({ kind: "stage", id: c.value, where: `${where} \u2192 __customInputFields__[${i}].pipelineStageId` });
8613
+ const pipe = customInputId(attr, ["pipelineId"]);
8614
+ if (pipe) refs.push({ kind: "pipeline", id: pipe, where: `${where} \u2192 __customInputFields__.pipelineId` });
8615
+ const stage = customInputId(attr, ["pipelineStageId"]);
8616
+ if (stage) refs.push({ kind: "stage", id: stage, where: `${where} \u2192 __customInputFields__.pipelineStageId` });
8617
+ break;
8618
+ }
8619
+ case "find_opportunity": {
8620
+ const pipe = customInputId(attr, ["pipeline_id", "pipelineId"]);
8621
+ if (pipe) refs.push({ kind: "pipeline", id: pipe, where: `${where} \u2192 __customInputFields__.pipeline_id` });
8622
+ break;
8623
+ }
8624
+ // ── if_else condition node: custom-field id is conditionSubType (NOT conditionValue) ──
8625
+ case "if_else": {
8626
+ const branches = Array.isArray(attr.branches) ? attr.branches : [];
8627
+ for (const b of branches) {
8628
+ const segs = b && typeof b === "object" && Array.isArray(b.segments) ? b.segments : [];
8629
+ for (const s of segs) {
8630
+ const conds = s && typeof s === "object" && Array.isArray(s.conditions) ? s.conditions : [];
8631
+ for (const raw of conds) {
8632
+ if (typeof raw !== "object" || raw === null) continue;
8633
+ const c = raw;
8634
+ if (c.conditionType === "contact_detail" && c.conditionSubType !== "tags" && isIdShaped(c.conditionSubType)) {
8635
+ refs.push({ kind: "custom_field", id: c.conditionSubType, where: `${where} \u2192 if_else condition (custom field)` });
8636
+ }
8637
+ }
8470
8638
  }
8471
8639
  }
8472
8640
  break;
8473
8641
  }
8474
8642
  }
8475
8643
  }
8644
+ function collectIds(envelope, listKeys) {
8645
+ const ids = /* @__PURE__ */ new Set();
8646
+ let arr = null;
8647
+ if (Array.isArray(envelope)) arr = envelope;
8648
+ else if (envelope && typeof envelope === "object") {
8649
+ const e = envelope;
8650
+ for (const k of listKeys) if (Array.isArray(e[k])) {
8651
+ arr = e[k];
8652
+ break;
8653
+ }
8654
+ }
8655
+ if (arr) for (const item of arr) {
8656
+ if (typeof item === "object" && item !== null) {
8657
+ const o = item;
8658
+ const id = typeof o.id === "string" ? o.id : typeof o._id === "string" ? o._id : null;
8659
+ if (id) ids.add(id);
8660
+ }
8661
+ }
8662
+ return { ids, found: arr !== null };
8663
+ }
8664
+ async function fetchAndBuildLookups(client, builderClient, locationId2, workflowExistence) {
8665
+ const fetches = {
8666
+ pipelines: client.get("/opportunities/pipelines", { params: { locationId: locationId2 } }),
8667
+ customFields: client.get(`/locations/${locationId2}/customFields`),
8668
+ users: client.get("/users/", { params: { locationId: locationId2 } }),
8669
+ forms: client.get("/forms/", { params: { locationId: locationId2 } }),
8670
+ calendars: client.get("/calendars/", { params: { locationId: locationId2 } }),
8671
+ surveys: client.get("/surveys/", { params: { locationId: locationId2 } })
8672
+ };
8673
+ const keys = Object.keys(fetches);
8674
+ const settled = await Promise.allSettled(Object.values(fetches));
8675
+ const data = {};
8676
+ const failed = /* @__PURE__ */ new Set();
8677
+ for (let i = 0; i < keys.length; i++) {
8678
+ if (settled[i].status === "fulfilled") data[keys[i]] = settled[i].value;
8679
+ else {
8680
+ data[keys[i]] = null;
8681
+ failed.add(keys[i]);
8682
+ }
8683
+ }
8684
+ const status = {
8685
+ pipeline: "loaded",
8686
+ stage: "loaded",
8687
+ custom_field: "loaded",
8688
+ user: "loaded",
8689
+ workflow: "loaded",
8690
+ form: "loaded",
8691
+ calendar: "loaded",
8692
+ survey: "loaded"
8693
+ };
8694
+ const pipelines = /* @__PURE__ */ new Set();
8695
+ const stages = /* @__PURE__ */ new Set();
8696
+ if (failed.has("pipelines")) {
8697
+ status.pipeline = "failed";
8698
+ status.stage = "failed";
8699
+ } else {
8700
+ try {
8701
+ const parsed = PipelinesResponseSchema.parse(data.pipelines);
8702
+ for (const p of parsed.pipelines) {
8703
+ pipelines.add(p.id);
8704
+ for (const s of p.stages) stages.add(s.id);
8705
+ }
8706
+ } catch {
8707
+ status.pipeline = "unparseable";
8708
+ status.stage = "unparseable";
8709
+ }
8710
+ }
8711
+ function setFrom(key, cat, listKeys) {
8712
+ if (failed.has(key)) {
8713
+ status[cat] = "failed";
8714
+ return /* @__PURE__ */ new Set();
8715
+ }
8716
+ const { ids, found } = collectIds(data[key], listKeys);
8717
+ if (!found) status[cat] = "unparseable";
8718
+ return ids;
8719
+ }
8720
+ const custom_field = setFrom("customFields", "custom_field", ["customFields"]);
8721
+ const user = setFrom("users", "user", ["users"]);
8722
+ const form = setFrom("forms", "form", ["forms"]);
8723
+ const calendar = setFrom("calendars", "calendar", ["calendars"]);
8724
+ const survey = setFrom("surveys", "survey", ["surveys"]);
8725
+ status.workflow = workflowExistence.complete ? "loaded" : "incomplete";
8726
+ return { pipelines, stages, custom_field, user, workflow: workflowExistence.ids, form, calendar, survey, status };
8727
+ }
8728
+ function auditOneWorkflow(workflow, selfId, lookups) {
8729
+ const refs = [];
8730
+ const triggers = Array.isArray(workflow.triggers) ? workflow.triggers : [];
8731
+ for (const t of triggers) extractFromTrigger(t, refs);
8732
+ const actions = Array.isArray(workflow.workflowData?.templates) ? workflow.workflowData.templates : [];
8733
+ for (const a of actions) extractFromAction(a, refs);
8734
+ return refs;
8735
+ }
8736
+ function setForCategory(lookups, kind) {
8737
+ switch (kind) {
8738
+ case "pipeline":
8739
+ return lookups.pipelines;
8740
+ case "stage":
8741
+ return lookups.stages;
8742
+ default:
8743
+ return lookups[kind];
8744
+ }
8745
+ }
8746
+ function checkRefs(refs, selfId, lookups) {
8747
+ const findings = [];
8748
+ for (const ref of refs) {
8749
+ const st = lookups.status[ref.kind];
8750
+ if (st !== "loaded") {
8751
+ findings.push({
8752
+ severity: "unverified",
8753
+ category: ref.kind,
8754
+ id: ref.id,
8755
+ where: ref.where,
8756
+ message: `${ref.kind} reference could not be verified \u2014 the ${ref.kind} list ${st === "incomplete" ? "is too large to fully load" : "failed to load or was unreadable"}. Re-run; not reported as broken.`
8757
+ });
8758
+ continue;
8759
+ }
8760
+ const valid = setForCategory(lookups, ref.kind).has(ref.id);
8761
+ if (valid) {
8762
+ if (ref.kind === "workflow" && ref.id === selfId)
8763
+ findings.push({ severity: "warning", category: ref.kind, id: ref.id, where: ref.where, message: `${ref.kind} id "${ref.id}" is valid (self-reference).` });
8764
+ } else {
8765
+ findings.push({
8766
+ severity: "error",
8767
+ category: ref.kind,
8768
+ id: ref.id,
8769
+ where: ref.where,
8770
+ message: `${ref.kind} id "${ref.id}" does not exist in this location \u2014 GHL will silently skip this action and all subsequent actions when the workflow runs.`
8771
+ });
8772
+ }
8773
+ }
8774
+ return findings;
8775
+ }
8776
+ async function fullWorkflowCatalog(builderClient) {
8777
+ const ids = /* @__PURE__ */ new Set();
8778
+ const rows = [];
8779
+ const limit = 100;
8780
+ let skip = 0;
8781
+ let complete = true;
8782
+ for (let page = 0; page < 50; page++) {
8783
+ const resp = await builderClient.listWorkflows(limit, skip);
8784
+ const found = Array.isArray(resp?.rows) || Array.isArray(resp?.workflows) || Array.isArray(resp);
8785
+ if (!found) {
8786
+ if (page === 0) throw new Error("Could not read the workflow list (unexpected response shape) \u2014 try again.");
8787
+ complete = false;
8788
+ break;
8789
+ }
8790
+ const arr = Array.isArray(resp?.rows) ? resp.rows : Array.isArray(resp?.workflows) ? resp.workflows : Array.isArray(resp) ? resp : [];
8791
+ for (const w of arr) {
8792
+ if (w && typeof w === "object") {
8793
+ const o = w;
8794
+ const id = typeof o._id === "string" ? o._id : typeof o.id === "string" ? o.id : null;
8795
+ if (id) {
8796
+ ids.add(id);
8797
+ rows.push({ id, name: typeof o.name === "string" ? o.name : id });
8798
+ }
8799
+ }
8800
+ }
8801
+ if (arr.length < limit) break;
8802
+ skip += limit;
8803
+ if (page === 49) complete = false;
8804
+ }
8805
+ return { ids, rows, complete };
8806
+ }
8476
8807
  function registerValidatorTools(server2, client, builderClient) {
8477
8808
  if (!builderClient) return;
8478
8809
  server2.tool(
8479
8810
  "validate_workflow",
8480
- "Pre-flight ID validation for a 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 this BEFORE publish_workflow when a workflow has been edited, or whenever a published workflow stops behaving as expected. Catches the silent-failure bug where invalid IDs make GHL skip all subsequent actions without warning. Returns a structured report of valid + missing references.",
8481
- {
8482
- workflowId: import_zod47.z.string().describe("The workflow ID to validate.")
8483
- },
8811
+ "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'.",
8812
+ { workflowId: import_zod47.z.string().describe("The workflow ID to validate.") },
8484
8813
  async ({ workflowId }) => {
8485
8814
  try {
8486
- let collectIds2 = function(envelope, listKey) {
8487
- const ids = /* @__PURE__ */ new Set();
8488
- if (envelope && typeof envelope === "object") {
8489
- const e = envelope;
8490
- const arr = Array.isArray(e[listKey]) ? e[listKey] : Array.isArray(envelope) ? envelope : [];
8491
- for (const item of arr) {
8492
- if (typeof item === "object" && item !== null) {
8493
- const o = item;
8494
- const id = typeof o.id === "string" ? o.id : typeof o._id === "string" ? o._id : null;
8495
- if (id) ids.add(id);
8496
- }
8497
- }
8498
- }
8499
- return ids;
8500
- };
8501
- var collectIds = collectIds2;
8502
8815
  const workflow = await builderClient.getWorkflow(workflowId);
8503
8816
  if (!workflow) return errorResponse(new Error(`Workflow ${workflowId} not found`));
8504
8817
  const refs = [];
8505
- const triggers = Array.isArray(workflow.triggers) ? workflow.triggers : [];
8506
- for (const t of triggers) {
8507
- extractFromTrigger(t, refs);
8508
- }
8509
- const actions = workflow.workflowData?.templates ?? [];
8510
- for (const a of actions) {
8511
- extractFromAction(a, refs);
8512
- }
8513
- if (refs.length === 0) {
8514
- const empty = {
8515
- workflowId,
8516
- workflowName: workflow.name,
8517
- status: "ok",
8518
- references_scanned: 0,
8519
- issues_count: 0,
8520
- findings: []
8521
- };
8522
- return jsonResponse(empty);
8523
- }
8524
- const refCategories = new Set(refs.map((r) => r.kind));
8525
- const locationId2 = client.defaultLocationId;
8526
- const fetches = {};
8527
- if (refCategories.has("pipeline") || refCategories.has("stage")) {
8528
- fetches.pipelines = client.get("/opportunities/pipelines", { params: { locationId: locationId2 } });
8529
- }
8530
- if (refCategories.has("custom_field")) {
8531
- fetches.customFields = client.get(`/locations/${locationId2}/customFields`);
8532
- }
8533
- if (refCategories.has("user")) {
8534
- fetches.users = client.get("/users/", { params: { locationId: locationId2 } });
8535
- }
8536
- if (refCategories.has("workflow")) {
8537
- fetches.workflows = builderClient.listWorkflows(200);
8538
- }
8539
- if (refCategories.has("form")) {
8540
- fetches.forms = client.get("/forms/", { params: { locationId: locationId2 } });
8541
- }
8542
- if (refCategories.has("calendar")) {
8543
- fetches.calendars = client.get("/calendars/", { params: { locationId: locationId2 } });
8544
- }
8545
- if (refCategories.has("survey")) {
8546
- fetches.surveys = client.get("/surveys/", { params: { locationId: locationId2 } });
8547
- }
8548
- const results = await Promise.allSettled(Object.values(fetches));
8549
- const keys = Object.keys(fetches);
8550
- const data = {};
8551
- for (let i = 0; i < keys.length; i++) {
8552
- const r = results[i];
8553
- data[keys[i]] = r.status === "fulfilled" ? r.value : null;
8554
- }
8555
- const validPipelineIds = /* @__PURE__ */ new Set();
8556
- const validStageIds = /* @__PURE__ */ new Set();
8557
- const stageToPipeline = /* @__PURE__ */ new Map();
8558
- if (data.pipelines) {
8559
- try {
8560
- const parsed = PipelinesResponseSchema.parse(data.pipelines);
8561
- for (const p of parsed.pipelines) {
8562
- validPipelineIds.add(p.id);
8563
- for (const s of p.stages) {
8564
- validStageIds.add(s.id);
8565
- stageToPipeline.set(s.id, p.id);
8566
- }
8567
- }
8568
- } catch {
8569
- }
8570
- }
8571
- const validCustomFieldIds = /* @__PURE__ */ new Set();
8572
- if (data.customFields && typeof data.customFields === "object") {
8573
- const cf = data.customFields;
8574
- const arr = Array.isArray(cf.customFields) ? cf.customFields : Array.isArray(cf) ? cf : [];
8575
- for (const f of arr) {
8576
- if (typeof f === "object" && f !== null && typeof f.id === "string") {
8577
- validCustomFieldIds.add(f.id);
8578
- }
8579
- }
8580
- }
8581
- const validUserIds = /* @__PURE__ */ new Set();
8582
- if (data.users && typeof data.users === "object") {
8583
- const u = data.users;
8584
- const arr = Array.isArray(u.users) ? u.users : Array.isArray(u) ? u : [];
8585
- for (const user of arr) {
8586
- if (typeof user === "object" && user !== null && typeof user.id === "string") {
8587
- validUserIds.add(user.id);
8588
- }
8589
- }
8590
- }
8591
- const validWorkflowIds = /* @__PURE__ */ new Set();
8592
- if (data.workflows && typeof data.workflows === "object") {
8593
- const w = data.workflows;
8594
- const arr = Array.isArray(w.rows) ? w.rows : Array.isArray(w.workflows) ? w.workflows : Array.isArray(w) ? w : [];
8595
- for (const wf of arr) {
8596
- if (typeof wf === "object" && wf !== null) {
8597
- const o = wf;
8598
- const id = typeof o._id === "string" ? o._id : typeof o.id === "string" ? o.id : null;
8599
- if (id) validWorkflowIds.add(id);
8600
- }
8601
- }
8602
- }
8603
- const validFormIds = collectIds2(data.forms, "forms");
8604
- const validCalendarIds = collectIds2(data.calendars, "calendars");
8605
- const validSurveyIds = collectIds2(data.surveys, "surveys");
8606
- const findings = [];
8607
- for (const ref of refs) {
8608
- let valid = false;
8609
- let extraMsg = "";
8610
- switch (ref.kind) {
8611
- case "pipeline":
8612
- valid = validPipelineIds.has(ref.id);
8613
- break;
8614
- case "stage":
8615
- valid = validStageIds.has(ref.id);
8616
- break;
8617
- case "custom_field":
8618
- valid = validCustomFieldIds.has(ref.id);
8619
- break;
8620
- case "user":
8621
- valid = validUserIds.has(ref.id);
8622
- break;
8623
- case "workflow":
8624
- valid = validWorkflowIds.has(ref.id);
8625
- if (valid && ref.id === workflowId) {
8626
- extraMsg = " (self-reference)";
8627
- }
8628
- break;
8629
- case "form":
8630
- valid = validFormIds.has(ref.id);
8631
- break;
8632
- case "calendar":
8633
- valid = validCalendarIds.has(ref.id);
8634
- break;
8635
- case "survey":
8636
- valid = validSurveyIds.has(ref.id);
8637
- break;
8638
- }
8639
- if (!valid) {
8640
- findings.push({
8641
- severity: "error",
8642
- category: ref.kind,
8643
- id: ref.id,
8644
- where: ref.where,
8645
- message: `${ref.kind} id "${ref.id}" does not exist in this location \u2014 GHL will silently skip this action and all subsequent actions when the workflow runs.`
8646
- });
8647
- } else if (extraMsg) {
8648
- findings.push({
8649
- severity: "warning",
8650
- category: ref.kind,
8651
- id: ref.id,
8652
- where: ref.where,
8653
- message: `${ref.kind} id "${ref.id}" is valid${extraMsg}.`
8654
- });
8655
- }
8656
- }
8818
+ for (const t of Array.isArray(workflow.triggers) ? workflow.triggers : []) extractFromTrigger(t, refs);
8819
+ for (const a of Array.isArray(workflow.workflowData?.templates) ? workflow.workflowData.templates : []) extractFromAction(a, refs);
8820
+ if (refs.length === 0)
8821
+ return jsonResponse({ workflowId, workflowName: workflow.name, status: "ok", references_scanned: 0, issues_count: 0, findings: [] });
8822
+ const needWorkflows = refs.some((r) => r.kind === "workflow");
8823
+ const catalog = needWorkflows ? await fullWorkflowCatalog(builderClient) : { ids: /* @__PURE__ */ new Set(), complete: true };
8824
+ const lookups = await fetchAndBuildLookups(client, builderClient, client.defaultLocationId, { ids: catalog.ids, complete: catalog.complete });
8825
+ const findings = checkRefs(refs, workflowId, lookups);
8657
8826
  const report = {
8658
8827
  workflowId,
8659
8828
  workflowName: workflow.name,
@@ -8668,6 +8837,69 @@ function registerValidatorTools(server2, client, builderClient) {
8668
8837
  }
8669
8838
  }
8670
8839
  );
8840
+ server2.tool(
8841
+ "audit_workflows",
8842
+ "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.",
8843
+ {},
8844
+ async () => {
8845
+ try {
8846
+ const locationId2 = client.defaultLocationId;
8847
+ const catalog = await fullWorkflowCatalog(builderClient);
8848
+ if (catalog.rows.length === 0)
8849
+ return jsonResponse({ location_id: locationId2, summary: { workflows_total: 0, workflows_scanned: 0, status: "ok", message: "No workflows found in this location." }, workflows_with_issues: [], unscannable: [] });
8850
+ const lookups = await fetchAndBuildLookups(client, builderClient, locationId2, { ids: catalog.ids, complete: catalog.complete });
8851
+ const SCAN_CAP = 300;
8852
+ const toScan = catalog.rows.slice(0, SCAN_CAP);
8853
+ const CONCURRENCY = 6;
8854
+ const results = [];
8855
+ const unscannable = [];
8856
+ let zeroRefCount = 0;
8857
+ for (let i = 0; i < toScan.length; i += CONCURRENCY) {
8858
+ const batch = toScan.slice(i, i + CONCURRENCY);
8859
+ await Promise.all(batch.map(async (row) => {
8860
+ try {
8861
+ const wf = await builderClient.getWorkflow(row.id);
8862
+ const refs = auditOneWorkflow(wf, row.id, lookups);
8863
+ if (refs.length === 0) zeroRefCount++;
8864
+ const findings = checkRefs(refs, row.id, lookups);
8865
+ results.push({ id: row.id, name: wf.name ?? row.name, status: wf.status, refs: refs.length, findings });
8866
+ } catch (e) {
8867
+ unscannable.push({ id: row.id, name: row.name, reason: e instanceof Error ? e.message : String(e) });
8868
+ }
8869
+ }));
8870
+ }
8871
+ 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));
8872
+ const errorsTotal = withErrors.reduce((n, w) => n + w.errors.length, 0);
8873
+ const unverifiedCats = ALL_CATEGORIES.filter((c) => lookups.status[c] !== "loaded");
8874
+ const unverifiedRefs = results.reduce((n, r) => n + r.findings.filter((f) => f.severity === "unverified").length, 0);
8875
+ return jsonResponse({
8876
+ location_id: locationId2,
8877
+ summary: {
8878
+ workflows_total: catalog.rows.length,
8879
+ workflows_scanned: results.length,
8880
+ enumeration_complete: catalog.complete,
8881
+ capped: catalog.rows.length > SCAN_CAP,
8882
+ workflows_with_errors: withErrors.length,
8883
+ errors_total: errorsTotal,
8884
+ workflows_unscannable: unscannable.length,
8885
+ workflows_zero_references: zeroRefCount,
8886
+ unverified: { categories_unloaded: unverifiedCats, references_unverified: unverifiedRefs },
8887
+ status: errorsTotal > 0 ? "issues_found" : "ok"
8888
+ },
8889
+ workflows_with_issues: withErrors,
8890
+ unscannable,
8891
+ notes: [
8892
+ ...catalog.complete ? [] : ["Workflow catalog exceeded the pagination backstop \u2014 some workflow-id references shown as unverified."],
8893
+ ...catalog.rows.length > SCAN_CAP ? [`Only the first ${SCAN_CAP} workflows were scanned (account has ${catalog.rows.length}).`] : [],
8894
+ ...unverifiedCats.length ? [`Could not fully load: ${unverifiedCats.join(", ")} \u2014 references to those are 'unverified', not 'broken'. Re-run.`] : [],
8895
+ "workflow_goal, goto, and unrecognized condition types are not deeply checked in this version."
8896
+ ]
8897
+ });
8898
+ } catch (error) {
8899
+ return errorResponse(error);
8900
+ }
8901
+ }
8902
+ );
8671
8903
  }
8672
8904
 
8673
8905
  // src/version-check.ts
@@ -8770,6 +9002,10 @@ function registerDiagnosticTools(server2, installedVersion, client, builderClien
8770
9002
  if (!registry2) {
8771
9003
  return { name: "Token registry", status: "skip", detail: "Not initialized \u2014 using env-var credentials only. switch_location won't auto-swap keys between sub-accounts." };
8772
9004
  }
9005
+ const loadFailure = registry2.getLoadFailure();
9006
+ if (loadFailure) {
9007
+ return { name: "Token registry", status: "fail", detail: loadFailure };
9008
+ }
8773
9009
  const locs = registry2.listLocations();
8774
9010
  const companies = registry2.listCompanyFirebases();
8775
9011
  const companyNote = companies.length ? ` ${companies.length} company Firebase credential(s) registered for multi-tenant workflow-builder access.` : "";
@@ -8833,7 +9069,7 @@ function resolveSnapshotAuth(client, registry2, companyIdParam) {
8833
9069
  const agencyKey = registry2?.getAgencyKey();
8834
9070
  if (!agencyKey) {
8835
9071
  throw new Error(
8836
- "Snapshots require an agency/company-scoped API key, and none is registered. This install only has sub-account Private Integration keys. Add the agency key (created at the AGENCY level in GHL Settings > Integrations) to the token registry, then retry."
9072
+ "Snapshots require an agency/company-scoped API key, and none is registered. This install only has sub-account Private Integration keys. Create a Private Integration at the AGENCY level in GHL (Agency Settings > Private Integrations), then run register_agency_key with that key, and retry."
8837
9073
  );
8838
9074
  }
8839
9075
  const storedCompanyId = client.defaultLocationId ? registry2?.getToken(client.defaultLocationId)?.companyId : void 0;
@@ -8926,6 +9162,137 @@ function registerSnapshotTools(server2, client, registry2) {
8926
9162
  );
8927
9163
  }
8928
9164
 
9165
+ // src/tools/phone.ts
9166
+ var import_zod49 = require("zod");
9167
+ var PhoneNumberSchema = import_zod49.z.object({ sid: import_zod49.z.string(), value: import_zod49.z.string(), title: import_zod49.z.string().optional() }).passthrough();
9168
+ var NumbersResponseSchema = import_zod49.z.object({ phoneNumbers: import_zod49.z.array(PhoneNumberSchema) }).passthrough();
9169
+ var PoolsResponseSchema = import_zod49.z.object({ pools: import_zod49.z.array(import_zod49.z.object({}).passthrough()) }).passthrough();
9170
+ function registerPhoneTools(server2, client) {
9171
+ safeTool(
9172
+ server2,
9173
+ "list_phone_numbers",
9174
+ "List the LC Phone numbers provisioned for a location (sid, number, label). Read-only. Use to verify or count purchased numbers (e.g. provisioning step 11). Number purchase is not exposed (billable write).",
9175
+ {
9176
+ locationId: import_zod49.z.string().optional().describe("Defaults to the active location.")
9177
+ },
9178
+ async ({ locationId: locationId2 }) => {
9179
+ const loc = client.resolveLocationId(locationId2);
9180
+ const raw = await client.get("/phone-system/numbers/", { params: { locationId: loc } });
9181
+ const parsed = NumbersResponseSchema.parse(raw);
9182
+ return {
9183
+ locationId: loc,
9184
+ count: parsed.phoneNumbers.length,
9185
+ phoneNumbers: parsed.phoneNumbers.map((n) => ({ sid: n.sid, number: n.value, label: n.title }))
9186
+ };
9187
+ }
9188
+ );
9189
+ safeTool(
9190
+ server2,
9191
+ "list_number_pools",
9192
+ "List LC Phone number pools configured for a location. Read-only.",
9193
+ {
9194
+ locationId: import_zod49.z.string().optional().describe("Defaults to the active location.")
9195
+ },
9196
+ async ({ locationId: locationId2 }) => {
9197
+ const loc = client.resolveLocationId(locationId2);
9198
+ const raw = await client.get("/phone-system/number-pools/", { params: { locationId: loc } });
9199
+ const parsed = PoolsResponseSchema.parse(raw);
9200
+ return { locationId: loc, count: parsed.pools.length, pools: parsed.pools };
9201
+ }
9202
+ );
9203
+ }
9204
+
9205
+ // src/tools/account-health.ts
9206
+ var import_zod50 = require("zod");
9207
+ var MetaTotalSchema = import_zod50.z.object({ meta: import_zod50.z.object({ total: import_zod50.z.number() }).passthrough() }).passthrough();
9208
+ var TotalSchema = import_zod50.z.object({ total: import_zod50.z.number() }).passthrough();
9209
+ var NumbersSchema = import_zod50.z.object({ phoneNumbers: import_zod50.z.array(import_zod50.z.unknown()) }).passthrough();
9210
+ var OPP_STATUSES = ["open", "won", "lost", "abandoned"];
9211
+ async function section(scope, fn) {
9212
+ try {
9213
+ return { ...await fn(), scope, status: "ok" };
9214
+ } catch (e) {
9215
+ return { status: "unavailable", scope, reason: errorMessage(e) };
9216
+ }
9217
+ }
9218
+ function registerAccountHealthTools(server2, client) {
9219
+ safeTool(
9220
+ server2,
9221
+ "get_account_health_summary",
9222
+ "Account-health summary for a location, composed from existing reads (GHL has no reporting API). Returns: total contacts + NEW contacts in the window; total opportunities + counts by status (open/won/lost/abandoned); total conversations; phone-number count. Every metric is explicitly labeled all_time vs window (with start/end) \u2014 windowed and all-time numbers are never conflated. Sections that can't be read return status:'unavailable' (never a misleading 0). Revenue and appointments are intentionally excluded (not reachable / too costly via the public API).",
9223
+ {
9224
+ locationId: import_zod50.z.string().optional().describe("Defaults to the active location."),
9225
+ windowDays: import_zod50.z.number().int().positive().max(365).optional().describe("Lookback window in days for windowed metrics (new contacts). Default 30.")
9226
+ },
9227
+ async ({ locationId: locationId2, windowDays }) => {
9228
+ const loc = client.resolveLocationId(locationId2);
9229
+ const days = windowDays ?? 30;
9230
+ const end = Date.now();
9231
+ const start = end - days * 864e5;
9232
+ const startISO = new Date(start).toISOString();
9233
+ const endISO = new Date(end).toISOString();
9234
+ const [
9235
+ contactsAllTime,
9236
+ contactsNewInWindow,
9237
+ opportunitiesTotal,
9238
+ opportunitiesByStatus,
9239
+ conversations,
9240
+ phoneNumbers
9241
+ ] = await Promise.all([
9242
+ section("all_time", async () => {
9243
+ const raw = await client.get("/contacts/", { params: { locationId: loc, limit: 1 } });
9244
+ return { total: MetaTotalSchema.parse(raw).meta.total };
9245
+ }),
9246
+ section("window", async () => {
9247
+ const raw = await client.post("/contacts/search", {
9248
+ body: {
9249
+ locationId: loc,
9250
+ pageLimit: 1,
9251
+ filters: [{ field: "dateAdded", operator: "range", value: { gte: start, lte: end } }]
9252
+ }
9253
+ });
9254
+ return { start: startISO, end: endISO, total: TotalSchema.parse(raw).total };
9255
+ }),
9256
+ // Overall opp total in its OWN section so a failed per-status query can't
9257
+ // blank a count we already have.
9258
+ section("all_time", async () => {
9259
+ const raw = await client.get("/opportunities/search", { params: { location_id: loc, limit: 1 } });
9260
+ return { total: MetaTotalSchema.parse(raw).meta.total };
9261
+ }),
9262
+ section("all_time", async () => {
9263
+ const counts = await Promise.all(
9264
+ OPP_STATUSES.map(async (s) => {
9265
+ const raw = await client.get("/opportunities/search", {
9266
+ params: { location_id: loc, status: s, limit: 1 }
9267
+ });
9268
+ return [s, MetaTotalSchema.parse(raw).meta.total];
9269
+ })
9270
+ );
9271
+ return { byStatus: Object.fromEntries(counts) };
9272
+ }),
9273
+ section("all_time", async () => {
9274
+ const raw = await client.get("/conversations/search", { params: { locationId: loc, limit: 1 } });
9275
+ return { total: TotalSchema.parse(raw).total };
9276
+ }),
9277
+ section("all_time", async () => {
9278
+ const raw = await client.get("/phone-system/numbers/", { params: { locationId: loc } });
9279
+ return { count: NumbersSchema.parse(raw).phoneNumbers.length };
9280
+ })
9281
+ ]);
9282
+ const sections = {
9283
+ contactsAllTime,
9284
+ contactsNewInWindow,
9285
+ opportunitiesTotal,
9286
+ opportunitiesByStatus,
9287
+ conversations,
9288
+ phoneNumbers
9289
+ };
9290
+ const unavailable = Object.entries(sections).filter(([, v]) => v.status === "unavailable").map(([k]) => k);
9291
+ return { locationId: loc, requestedWindow: { days, start: startISO, end: endISO }, sections, unavailable };
9292
+ }
9293
+ );
9294
+ }
9295
+
8929
9296
  // src/tools/index.ts
8930
9297
  var publicApiTools = [
8931
9298
  [registerContactTools, "contacts"],
@@ -8957,7 +9324,9 @@ var publicApiTools = [
8957
9324
  [registerDocumentTools, "documents"],
8958
9325
  [registerBulkOperationTools, "bulk-operations"],
8959
9326
  [registerAccountExportTools, "account-export"],
8960
- [registerTemplateDeployerTools, "template-deployer"]
9327
+ [registerTemplateDeployerTools, "template-deployer"],
9328
+ [registerPhoneTools, "phone"],
9329
+ [registerAccountHealthTools, "account-health"]
8961
9330
  ];
8962
9331
  var internalApiTools = [
8963
9332
  [registerWorkflowBuilderTools, "workflow-builder"],
@@ -9126,7 +9495,19 @@ function registerMetaTools(server2, installedVersion) {
9126
9495
  }
9127
9496
 
9128
9497
  // src/index.ts
9129
- var pkg = require_package();
9498
+ var bundledPkg = require_package();
9499
+ var pkg = (() => {
9500
+ try {
9501
+ const onDisk = JSON.parse(
9502
+ fs5.readFileSync(path5.resolve(__dirname, "..", "package.json"), "utf8")
9503
+ );
9504
+ if (typeof onDisk.version === "string" && onDisk.version.length > 0) {
9505
+ return { version: onDisk.version };
9506
+ }
9507
+ } catch {
9508
+ }
9509
+ return bundledPkg;
9510
+ })();
9130
9511
  dotenv2.config();
9131
9512
  process.on("unhandledRejection", (reason) => {
9132
9513
  process.stderr.write(`[ghl-mcp] Unhandled rejection: ${reason}
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "@elitedcs/ghl-mcp",
3
- "version": "3.31.0",
3
+ "version": "3.33.0",
4
4
  "mcpName": "io.github.drjerryrelth/ghl-command",
5
- "description": "GoHighLevel MCP Server for Claude. 214 tools — full CRM, automation, marketing control, and the only programmatic GHL workflow builder, now multi-tenant across client accounts.",
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",
7
7
  "bin": {
8
8
  "ghl-mcp": "dist/index.js"