@elitedcs/ghl-mcp 3.30.0 → 3.32.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,56 @@
1
1
  # Changelog
2
2
 
3
+ ## 3.32.0 — Account-health summary + phone reads
4
+
5
+ Three read tools. The composite is the second roadmap build and the first
6
+ "how's the account doing" answer (GHL has no public reporting API, confirmed by
7
+ probe — so this composes existing reads).
8
+
9
+ - **`get_account_health_summary`** — one call returns, for a location: total
10
+ contacts + NEW contacts in a window (default 30d), total opportunities + counts
11
+ by status (open/won/lost/abandoned), total conversations, and phone-number
12
+ count.
13
+ - **`list_phone_numbers`** — provisioned LC Phone numbers (sid, number, label).
14
+ - **`list_number_pools`** — configured number pools.
15
+
16
+ **Honesty by construction.** Every metric is explicitly labeled `scope`
17
+ (`all_time` vs `window`, with start/end on windowed ones) so an all-time number
18
+ can never be read as a recent one. Any metric that can't be read returns
19
+ `{status:"unavailable", reason}` — never a misleading `0`. Each sub-read is
20
+ isolated, so one failure degrades only its own section, not the whole summary.
21
+
22
+ **Scope (verified against the live API):** windowed *new contacts* use the
23
+ `/contacts/search` `dateAdded` range filter; opportunity status counts use
24
+ filtered `meta.total`. Conversations are all-time only (the API's
25
+ `startAfterDate` is a cursor, not a count filter). Revenue (transactions are
26
+ 403 for sub-account tokens) and appointments (no location-wide events endpoint)
27
+ are intentionally excluded.
28
+
29
+ ## 3.31.0 — Snapshots: list + share-link (agency tooling)
30
+
31
+ Two new agency-level tools, the first build toward removing manual steps from the
32
+ client-provisioning runbook (snapshot selection + handoff for new sub-accounts).
33
+
34
+ - **`list_snapshots`** — list the agency's snapshots (`id`, `name`, `type`) so you
35
+ can pick the right one by name. Read-only.
36
+ - **`create_snapshot_share_link`** — mint a load/share link for a snapshot
37
+ (`gohighlevel.com/?share=…`) to import it into a sub-account. Requires an explicit
38
+ `share_type` (`link`, `permanent_link`, `agency_link`, `location_link`,
39
+ `marketplace_link`).
40
+
41
+ Both use the agency/company-scoped key (`getAgencyKey()`), not a sub-account PIT —
42
+ snapshots are an agency resource. The tools resolve `companyId` with strict rules
43
+ (explicit param > active location's company; mismatch is rejected, not guessed) so
44
+ a multi-tenant install can't list the wrong agency's snapshots, and they fail with
45
+ an actionable message when no agency key is registered.
46
+
47
+ **Notes / limits (verified against the live API):**
48
+ - Creating a sub-account from a snapshot is NOT exposed: `POST /locations/` returns
49
+ 401 for PIT auth. Snapshot *apply* stays a guided GHL-UI step.
50
+ - `create_snapshot_share_link` is not idempotent (each call mints a new link, no
51
+ revoke API — remove in the UI). The HTTP client gained an internal `noRetry`
52
+ option so a lost-response retry can't silently create a duplicate.
53
+
3
54
  ## 3.30.0 — Workflow trigger stays active after edit/publish (Bug 7)
4
55
 
5
56
  Editing a trigger on a published workflow via `update_workflow_actions` silently
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.30.0",
34
+ version: "3.32.0",
35
35
  mcpName: "io.github.drjerryrelth/ghl-command",
36
- description: "GoHighLevel MCP Server for Claude. 212 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. 217 tools \u2014 full CRM, automation, marketing control, 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"
@@ -203,7 +203,7 @@ var GHLClient = class {
203
203
  if (error instanceof Error && error.name === "AbortError") {
204
204
  throw new Error(`Request timeout (30s): ${method} ${path6}`);
205
205
  }
206
- if (attempt < MAX_RETRIES) {
206
+ if (!options.noRetry && attempt < MAX_RETRIES) {
207
207
  const delay4 = computeRetryDelay(null, attempt, BASE_DELAY_MS);
208
208
  process.stderr.write(`[ghl-mcp] Network error on ${method} ${path6}, retry ${attempt + 1}/${MAX_RETRIES} in ${delay4}ms
209
209
  `);
@@ -214,7 +214,7 @@ var GHLClient = class {
214
214
  } finally {
215
215
  clearTimeout(timeout);
216
216
  }
217
- if ((response.status === 429 || response.status >= 500) && attempt < MAX_RETRIES) {
217
+ if (!options.noRetry && (response.status === 429 || response.status >= 500) && attempt < MAX_RETRIES) {
218
218
  const delay4 = computeRetryDelay(response.headers.get("Retry-After"), attempt, BASE_DELAY_MS);
219
219
  process.stderr.write(`[ghl-mcp] ${response.status} on ${method} ${path6}, retry ${attempt + 1}/${MAX_RETRIES} in ${delay4}ms
220
220
  `);
@@ -8817,6 +8817,246 @@ function registerDiagnosticTools(server2, installedVersion, client, builderClien
8817
8817
  );
8818
8818
  }
8819
8819
 
8820
+ // src/tools/snapshots.ts
8821
+ var import_zod48 = require("zod");
8822
+ var SnapshotSchema = import_zod48.z.object({ id: import_zod48.z.string(), name: import_zod48.z.string(), type: import_zod48.z.string() }).passthrough();
8823
+ var SnapshotsResponseSchema = import_zod48.z.object({ snapshots: import_zod48.z.array(SnapshotSchema) }).passthrough();
8824
+ var ShareLinkResponseSchema = import_zod48.z.object({ id: import_zod48.z.string(), shareLink: import_zod48.z.string() }).passthrough();
8825
+ var SHARE_TYPES = [
8826
+ "link",
8827
+ "permanent_link",
8828
+ "agency_link",
8829
+ "location_link",
8830
+ "marketplace_link"
8831
+ ];
8832
+ function resolveSnapshotAuth(client, registry2, companyIdParam) {
8833
+ const agencyKey = registry2?.getAgencyKey();
8834
+ if (!agencyKey) {
8835
+ 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."
8837
+ );
8838
+ }
8839
+ const storedCompanyId = client.defaultLocationId ? registry2?.getToken(client.defaultLocationId)?.companyId : void 0;
8840
+ const param = companyIdParam?.trim() || void 0;
8841
+ if (param && storedCompanyId && param !== storedCompanyId) {
8842
+ throw new Error(
8843
+ `companyId mismatch: you passed ${param}, but the active location belongs to company ${storedCompanyId}. Refusing to guess. Switch to a location in the intended company, or pass the matching companyId.`
8844
+ );
8845
+ }
8846
+ const companyId = param ?? storedCompanyId;
8847
+ if (!companyId) {
8848
+ throw new Error(
8849
+ "Could not determine the companyId for this snapshot call. The active location has no stored company. Pass companyId explicitly, or run register_location / switch_location for the active location so its company is recorded."
8850
+ );
8851
+ }
8852
+ return { agencyClient: new GHLClient({ apiKey: agencyKey }), companyId };
8853
+ }
8854
+ function explainSnapshotError(error, companyId) {
8855
+ const message = error instanceof Error ? error.message : String(error);
8856
+ if (message.includes("401") || message.includes("Unauthorized") || message.includes("403")) {
8857
+ throw new Error(
8858
+ `${message}
8859
+
8860
+ The agency key is scoped to a single company and may not cover company ${companyId}. Snapshots only list for the agency that owns the key. Use a companyId belonging to the agency key, or register that agency's own key.`
8861
+ );
8862
+ }
8863
+ throw error instanceof Error ? error : new Error(message);
8864
+ }
8865
+ function registerSnapshotTools(server2, client, registry2) {
8866
+ safeTool(
8867
+ server2,
8868
+ "list_snapshots",
8869
+ "List the agency's GHL snapshots (id, name, type) so you can pick the right one by name before applying it to a sub-account. Reads the agency/company-scoped key (not a sub-account PIT). companyId defaults to the active location's company; pass it explicitly to target a different company you have the agency key for. Read-only.",
8870
+ {
8871
+ companyId: import_zod48.z.string().optional().describe(
8872
+ "Agency/company ID whose snapshots to list. Defaults to the active location's company. Must match the company your agency key is scoped to."
8873
+ )
8874
+ },
8875
+ async ({ companyId }) => {
8876
+ const { agencyClient, companyId: resolved } = resolveSnapshotAuth(client, registry2, companyId);
8877
+ let raw;
8878
+ try {
8879
+ raw = await agencyClient.get("/snapshots/", { params: { companyId: resolved } });
8880
+ } catch (error) {
8881
+ explainSnapshotError(error, resolved);
8882
+ }
8883
+ const parsed = SnapshotsResponseSchema.parse(raw);
8884
+ return {
8885
+ companyId: resolved,
8886
+ count: parsed.snapshots.length,
8887
+ snapshots: parsed.snapshots
8888
+ };
8889
+ }
8890
+ );
8891
+ safeTool(
8892
+ server2,
8893
+ "create_snapshot_share_link",
8894
+ "Create a shareable load link for one of the agency's snapshots (returns a gohighlevel.com/?share=... URL to import the snapshot into a sub-account). Uses the agency/company-scoped key. WARNING: this is NOT idempotent \u2014 each call mints a NEW link, and there is no API to list or revoke links (revoke in the GHL UI under the snapshot's share settings). Pick share_type deliberately. Use list_snapshots first to get the snapshot id.",
8895
+ {
8896
+ snapshot_id: import_zod48.z.string().describe("The snapshot id to share (from list_snapshots)."),
8897
+ share_type: import_zod48.z.enum(SHARE_TYPES).describe(
8898
+ "Share link type. 'link' = standard share link; 'permanent_link' = non-expiring; 'agency_link' = share to agencies; 'location_link' = load into a sub-account/location; 'marketplace_link' = marketplace listing. No default \u2014 choose intentionally."
8899
+ ),
8900
+ companyId: import_zod48.z.string().optional().describe(
8901
+ "Agency/company ID that owns the snapshot. Defaults to the active location's company. Must match the company your agency key is scoped to."
8902
+ )
8903
+ },
8904
+ async ({ snapshot_id, share_type, companyId }) => {
8905
+ const { agencyClient, companyId: resolved } = resolveSnapshotAuth(client, registry2, companyId);
8906
+ let raw;
8907
+ try {
8908
+ raw = await agencyClient.post("/snapshots/share/link", {
8909
+ params: { companyId: resolved },
8910
+ body: { snapshot_id, share_type },
8911
+ noRetry: true
8912
+ });
8913
+ } catch (error) {
8914
+ explainSnapshotError(error, resolved);
8915
+ }
8916
+ const parsed = ShareLinkResponseSchema.parse(raw);
8917
+ return {
8918
+ companyId: resolved,
8919
+ snapshot_id,
8920
+ share_type,
8921
+ id: parsed.id,
8922
+ shareLink: parsed.shareLink,
8923
+ _note: "A new share link was created. There is no API to revoke it \u2014 remove it in the GHL UI (snapshot share settings) if needed."
8924
+ };
8925
+ }
8926
+ );
8927
+ }
8928
+
8929
+ // src/tools/phone.ts
8930
+ var import_zod49 = require("zod");
8931
+ var PhoneNumberSchema = import_zod49.z.object({ sid: import_zod49.z.string(), value: import_zod49.z.string(), title: import_zod49.z.string().optional() }).passthrough();
8932
+ var NumbersResponseSchema = import_zod49.z.object({ phoneNumbers: import_zod49.z.array(PhoneNumberSchema) }).passthrough();
8933
+ var PoolsResponseSchema = import_zod49.z.object({ pools: import_zod49.z.array(import_zod49.z.object({}).passthrough()) }).passthrough();
8934
+ function registerPhoneTools(server2, client) {
8935
+ safeTool(
8936
+ server2,
8937
+ "list_phone_numbers",
8938
+ "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).",
8939
+ {
8940
+ locationId: import_zod49.z.string().optional().describe("Defaults to the active location.")
8941
+ },
8942
+ async ({ locationId: locationId2 }) => {
8943
+ const loc = client.resolveLocationId(locationId2);
8944
+ const raw = await client.get("/phone-system/numbers/", { params: { locationId: loc } });
8945
+ const parsed = NumbersResponseSchema.parse(raw);
8946
+ return {
8947
+ locationId: loc,
8948
+ count: parsed.phoneNumbers.length,
8949
+ phoneNumbers: parsed.phoneNumbers.map((n) => ({ sid: n.sid, number: n.value, label: n.title }))
8950
+ };
8951
+ }
8952
+ );
8953
+ safeTool(
8954
+ server2,
8955
+ "list_number_pools",
8956
+ "List LC Phone number pools configured for a location. Read-only.",
8957
+ {
8958
+ locationId: import_zod49.z.string().optional().describe("Defaults to the active location.")
8959
+ },
8960
+ async ({ locationId: locationId2 }) => {
8961
+ const loc = client.resolveLocationId(locationId2);
8962
+ const raw = await client.get("/phone-system/number-pools/", { params: { locationId: loc } });
8963
+ const parsed = PoolsResponseSchema.parse(raw);
8964
+ return { locationId: loc, count: parsed.pools.length, pools: parsed.pools };
8965
+ }
8966
+ );
8967
+ }
8968
+
8969
+ // src/tools/account-health.ts
8970
+ var import_zod50 = require("zod");
8971
+ var MetaTotalSchema = import_zod50.z.object({ meta: import_zod50.z.object({ total: import_zod50.z.number() }).passthrough() }).passthrough();
8972
+ var TotalSchema = import_zod50.z.object({ total: import_zod50.z.number() }).passthrough();
8973
+ var NumbersSchema = import_zod50.z.object({ phoneNumbers: import_zod50.z.array(import_zod50.z.unknown()) }).passthrough();
8974
+ var OPP_STATUSES = ["open", "won", "lost", "abandoned"];
8975
+ async function section(scope, fn) {
8976
+ try {
8977
+ return { ...await fn(), scope, status: "ok" };
8978
+ } catch (e) {
8979
+ return { status: "unavailable", scope, reason: errorMessage(e) };
8980
+ }
8981
+ }
8982
+ function registerAccountHealthTools(server2, client) {
8983
+ safeTool(
8984
+ server2,
8985
+ "get_account_health_summary",
8986
+ "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).",
8987
+ {
8988
+ locationId: import_zod50.z.string().optional().describe("Defaults to the active location."),
8989
+ windowDays: import_zod50.z.number().int().positive().max(365).optional().describe("Lookback window in days for windowed metrics (new contacts). Default 30.")
8990
+ },
8991
+ async ({ locationId: locationId2, windowDays }) => {
8992
+ const loc = client.resolveLocationId(locationId2);
8993
+ const days = windowDays ?? 30;
8994
+ const end = Date.now();
8995
+ const start = end - days * 864e5;
8996
+ const startISO = new Date(start).toISOString();
8997
+ const endISO = new Date(end).toISOString();
8998
+ const [
8999
+ contactsAllTime,
9000
+ contactsNewInWindow,
9001
+ opportunitiesTotal,
9002
+ opportunitiesByStatus,
9003
+ conversations,
9004
+ phoneNumbers
9005
+ ] = await Promise.all([
9006
+ section("all_time", async () => {
9007
+ const raw = await client.get("/contacts/", { params: { locationId: loc, limit: 1 } });
9008
+ return { total: MetaTotalSchema.parse(raw).meta.total };
9009
+ }),
9010
+ section("window", async () => {
9011
+ const raw = await client.post("/contacts/search", {
9012
+ body: {
9013
+ locationId: loc,
9014
+ pageLimit: 1,
9015
+ filters: [{ field: "dateAdded", operator: "range", value: { gte: start, lte: end } }]
9016
+ }
9017
+ });
9018
+ return { start: startISO, end: endISO, total: TotalSchema.parse(raw).total };
9019
+ }),
9020
+ // Overall opp total in its OWN section so a failed per-status query can't
9021
+ // blank a count we already have.
9022
+ section("all_time", async () => {
9023
+ const raw = await client.get("/opportunities/search", { params: { location_id: loc, limit: 1 } });
9024
+ return { total: MetaTotalSchema.parse(raw).meta.total };
9025
+ }),
9026
+ section("all_time", async () => {
9027
+ const counts = await Promise.all(
9028
+ OPP_STATUSES.map(async (s) => {
9029
+ const raw = await client.get("/opportunities/search", {
9030
+ params: { location_id: loc, status: s, limit: 1 }
9031
+ });
9032
+ return [s, MetaTotalSchema.parse(raw).meta.total];
9033
+ })
9034
+ );
9035
+ return { byStatus: Object.fromEntries(counts) };
9036
+ }),
9037
+ section("all_time", async () => {
9038
+ const raw = await client.get("/conversations/search", { params: { locationId: loc, limit: 1 } });
9039
+ return { total: TotalSchema.parse(raw).total };
9040
+ }),
9041
+ section("all_time", async () => {
9042
+ const raw = await client.get("/phone-system/numbers/", { params: { locationId: loc } });
9043
+ return { count: NumbersSchema.parse(raw).phoneNumbers.length };
9044
+ })
9045
+ ]);
9046
+ const sections = {
9047
+ contactsAllTime,
9048
+ contactsNewInWindow,
9049
+ opportunitiesTotal,
9050
+ opportunitiesByStatus,
9051
+ conversations,
9052
+ phoneNumbers
9053
+ };
9054
+ const unavailable = Object.entries(sections).filter(([, v]) => v.status === "unavailable").map(([k]) => k);
9055
+ return { locationId: loc, requestedWindow: { days, start: startISO, end: endISO }, sections, unavailable };
9056
+ }
9057
+ );
9058
+ }
9059
+
8820
9060
  // src/tools/index.ts
8821
9061
  var publicApiTools = [
8822
9062
  [registerContactTools, "contacts"],
@@ -8848,7 +9088,9 @@ var publicApiTools = [
8848
9088
  [registerDocumentTools, "documents"],
8849
9089
  [registerBulkOperationTools, "bulk-operations"],
8850
9090
  [registerAccountExportTools, "account-export"],
8851
- [registerTemplateDeployerTools, "template-deployer"]
9091
+ [registerTemplateDeployerTools, "template-deployer"],
9092
+ [registerPhoneTools, "phone"],
9093
+ [registerAccountHealthTools, "account-health"]
8852
9094
  ];
8853
9095
  var internalApiTools = [
8854
9096
  [registerWorkflowBuilderTools, "workflow-builder"],
@@ -8865,12 +9107,14 @@ var internalApiTools = [
8865
9107
  var VALIDATORS_MODULE = "validators";
8866
9108
  var DIAGNOSTICS_MODULE = "diagnostics";
8867
9109
  var LOCATION_SWITCHER_MODULE = "location-switcher";
9110
+ var SNAPSHOTS_MODULE = "snapshots";
8868
9111
  var KNOWN_MODULES = /* @__PURE__ */ new Set([
8869
9112
  ...publicApiTools.map(([, label]) => label),
8870
9113
  ...internalApiTools.map(([, label]) => label),
8871
9114
  VALIDATORS_MODULE,
8872
9115
  DIAGNOSTICS_MODULE,
8873
- LOCATION_SWITCHER_MODULE
9116
+ LOCATION_SWITCHER_MODULE,
9117
+ SNAPSHOTS_MODULE
8874
9118
  ]);
8875
9119
  function registerAllTools(server2, client, registry2, mcpVersion, env = process.env) {
8876
9120
  const config3 = parseAllowlist(env);
@@ -8892,6 +9136,7 @@ function registerAllTools(server2, client, registry2, mcpVersion, env = process.
8892
9136
  builderClient,
8893
9137
  registry2 ?? null
8894
9138
  );
9139
+ registerSnapshotTools(wrap(SNAPSHOTS_MODULE), client, registry2);
8895
9140
  registerLocationSwitcherTools(
8896
9141
  wrap(LOCATION_SWITCHER_MODULE),
8897
9142
  client,
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "@elitedcs/ghl-mcp",
3
- "version": "3.30.0",
3
+ "version": "3.32.0",
4
4
  "mcpName": "io.github.drjerryrelth/ghl-command",
5
- "description": "GoHighLevel MCP Server for Claude. 212 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. 217 tools — full CRM, automation, marketing control, 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"