@elitedcs/ghl-mcp 3.66.1 → 3.67.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 +70 -0
- package/README.md +4 -4
- package/dist/index.js +746 -84
- package/guide/guide.html +101 -7
- package/package.json +2 -2
package/dist/index.js
CHANGED
|
@@ -147,10 +147,10 @@ var init_ghl_client = __esm({
|
|
|
147
147
|
throw new Error(`Request timeout (30s): ${method} ${path15}`);
|
|
148
148
|
}
|
|
149
149
|
if (!options.noRetry && attempt < MAX_RETRIES) {
|
|
150
|
-
const
|
|
151
|
-
process.stderr.write(`[ghl-mcp] Network error on ${method} ${path15}, retry ${attempt + 1}/${MAX_RETRIES} in ${
|
|
150
|
+
const delay5 = computeRetryDelay(null, attempt, BASE_DELAY_MS);
|
|
151
|
+
process.stderr.write(`[ghl-mcp] Network error on ${method} ${path15}, retry ${attempt + 1}/${MAX_RETRIES} in ${delay5}ms
|
|
152
152
|
`);
|
|
153
|
-
await new Promise((r) => setTimeout(r,
|
|
153
|
+
await new Promise((r) => setTimeout(r, delay5));
|
|
154
154
|
return this.request(method, path15, options, attempt + 1);
|
|
155
155
|
}
|
|
156
156
|
throw error;
|
|
@@ -158,10 +158,10 @@ var init_ghl_client = __esm({
|
|
|
158
158
|
clearTimeout(timeout);
|
|
159
159
|
}
|
|
160
160
|
if (!options.noRetry && (response.status === 429 || response.status >= 500) && attempt < MAX_RETRIES) {
|
|
161
|
-
const
|
|
162
|
-
process.stderr.write(`[ghl-mcp] ${response.status} on ${method} ${path15}, retry ${attempt + 1}/${MAX_RETRIES} in ${
|
|
161
|
+
const delay5 = computeRetryDelay(response.headers.get("Retry-After"), attempt, BASE_DELAY_MS);
|
|
162
|
+
process.stderr.write(`[ghl-mcp] ${response.status} on ${method} ${path15}, retry ${attempt + 1}/${MAX_RETRIES} in ${delay5}ms
|
|
163
163
|
`);
|
|
164
|
-
await new Promise((r) => setTimeout(r,
|
|
164
|
+
await new Promise((r) => setTimeout(r, delay5));
|
|
165
165
|
return this.request(method, path15, options, attempt + 1);
|
|
166
166
|
}
|
|
167
167
|
if (!response.ok) {
|
|
@@ -1648,7 +1648,7 @@ Note: Firebase credentials rejected (${fb.error}).`;
|
|
|
1648
1648
|
const telemetryLine = telemetryDisabled(process.env) ? "" : `
|
|
1649
1649
|
|
|
1650
1650
|
${TELEMETRY_DISCLOSURE}`;
|
|
1651
|
-
const finishedCount = isFree ? "111" : "
|
|
1651
|
+
const finishedCount = isFree ? "111" : "241";
|
|
1652
1652
|
const freeTip = isFree ? `
|
|
1653
1653
|
|
|
1654
1654
|
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` : "";
|
|
@@ -1693,7 +1693,7 @@ Free tier: read-only. Write tools stay visible but answer with upgrade info inst
|
|
|
1693
1693
|
function registerEnableWorkflowBuilderTool(server2) {
|
|
1694
1694
|
server2.tool(
|
|
1695
1695
|
"enable_workflow_builder",
|
|
1696
|
-
"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
|
|
1696
|
+
"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 186 to 241 after the next Claude restart.",
|
|
1697
1697
|
{
|
|
1698
1698
|
// v3.25.0: one-paste path. Tool runs `auto_capture_firebase_script` to
|
|
1699
1699
|
// get the console script; the script returns a JSON object that pastes
|
|
@@ -2854,9 +2854,9 @@ var require_package = __commonJS({
|
|
|
2854
2854
|
"package.json"(exports2, module2) {
|
|
2855
2855
|
module2.exports = {
|
|
2856
2856
|
name: "@elitedcs/ghl-mcp",
|
|
2857
|
-
version: "3.
|
|
2857
|
+
version: "3.67.0",
|
|
2858
2858
|
mcpName: "io.github.drjerryrelth/ghl-command",
|
|
2859
|
-
description: "GoHighLevel MCP Server for Claude.
|
|
2859
|
+
description: "GoHighLevel MCP Server for Claude. 241 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.",
|
|
2860
2860
|
main: "dist/index.js",
|
|
2861
2861
|
bin: {
|
|
2862
2862
|
"ghl-mcp": "dist/index.js"
|
|
@@ -6187,10 +6187,10 @@ var WorkflowBuilderClient = class _WorkflowBuilderClient {
|
|
|
6187
6187
|
throw new Error(`Request timeout (30s): ${method} ${urlPath}`);
|
|
6188
6188
|
}
|
|
6189
6189
|
if (attempt < MAX_RETRIES2) {
|
|
6190
|
-
const
|
|
6191
|
-
process.stderr.write(`[ghl-mcp] Network error on ${method} ${urlPath}, retry ${attempt + 1}/${MAX_RETRIES2} in ${
|
|
6190
|
+
const delay5 = computeRetryDelay(null, attempt, BASE_DELAY_MS2);
|
|
6191
|
+
process.stderr.write(`[ghl-mcp] Network error on ${method} ${urlPath}, retry ${attempt + 1}/${MAX_RETRIES2} in ${delay5}ms
|
|
6192
6192
|
`);
|
|
6193
|
-
await new Promise((r) => setTimeout(r,
|
|
6193
|
+
await new Promise((r) => setTimeout(r, delay5));
|
|
6194
6194
|
return this.request(method, urlPath, body, attempt + 1);
|
|
6195
6195
|
}
|
|
6196
6196
|
throw error;
|
|
@@ -6198,10 +6198,10 @@ var WorkflowBuilderClient = class _WorkflowBuilderClient {
|
|
|
6198
6198
|
clearTimeout(timeout);
|
|
6199
6199
|
}
|
|
6200
6200
|
if ((response.status === 429 || response.status >= 500) && attempt < MAX_RETRIES2) {
|
|
6201
|
-
const
|
|
6202
|
-
process.stderr.write(`[ghl-mcp] ${response.status} on ${method} ${urlPath}, retry ${attempt + 1}/${MAX_RETRIES2} in ${
|
|
6201
|
+
const delay5 = computeRetryDelay(response.headers.get("Retry-After"), attempt, BASE_DELAY_MS2);
|
|
6202
|
+
process.stderr.write(`[ghl-mcp] ${response.status} on ${method} ${urlPath}, retry ${attempt + 1}/${MAX_RETRIES2} in ${delay5}ms
|
|
6203
6203
|
`);
|
|
6204
|
-
await new Promise((r) => setTimeout(r,
|
|
6204
|
+
await new Promise((r) => setTimeout(r, delay5));
|
|
6205
6205
|
return this.request(method, urlPath, body, attempt + 1);
|
|
6206
6206
|
}
|
|
6207
6207
|
if (!response.ok) {
|
|
@@ -10182,6 +10182,18 @@ function summarizeWebhookWorkflow(workflow) {
|
|
|
10182
10182
|
};
|
|
10183
10183
|
}
|
|
10184
10184
|
var WorkflowListSchema = import_zod34.z.object({ rows: import_zod34.z.array(import_zod34.z.object({ _id: import_zod34.z.string().optional(), id: import_zod34.z.string().optional() }).passthrough()).optional() }).passthrough();
|
|
10185
|
+
async function loadWebhooks(client, limit) {
|
|
10186
|
+
const listed = WorkflowListSchema.parse(await client.listWorkflows(limit, 0));
|
|
10187
|
+
const rows = listed.rows ?? [];
|
|
10188
|
+
const webhooks = [];
|
|
10189
|
+
for (const row of rows) {
|
|
10190
|
+
const id = row._id ?? row.id;
|
|
10191
|
+
if (!id) continue;
|
|
10192
|
+
const summary = summarizeWebhookWorkflow(await client.getWorkflow(id));
|
|
10193
|
+
if (summary) webhooks.push(summary);
|
|
10194
|
+
}
|
|
10195
|
+
return { webhooks, scanned: rows.length };
|
|
10196
|
+
}
|
|
10185
10197
|
function registerWebhookTools(server2, builderClient) {
|
|
10186
10198
|
const requireClient = () => {
|
|
10187
10199
|
if (!builderClient) {
|
|
@@ -10191,18 +10203,6 @@ function registerWebhookTools(server2, builderClient) {
|
|
|
10191
10203
|
}
|
|
10192
10204
|
return builderClient;
|
|
10193
10205
|
};
|
|
10194
|
-
const loadWebhooks = async (client, limit) => {
|
|
10195
|
-
const listed = WorkflowListSchema.parse(await client.listWorkflows(limit, 0));
|
|
10196
|
-
const rows = listed.rows ?? [];
|
|
10197
|
-
const webhooks = [];
|
|
10198
|
-
for (const row of rows) {
|
|
10199
|
-
const id = row._id ?? row.id;
|
|
10200
|
-
if (!id) continue;
|
|
10201
|
-
const summary = summarizeWebhookWorkflow(await client.getWorkflow(id));
|
|
10202
|
-
if (summary) webhooks.push(summary);
|
|
10203
|
-
}
|
|
10204
|
-
return { webhooks, scanned: rows.length };
|
|
10205
|
-
};
|
|
10206
10206
|
server2.tool(
|
|
10207
10207
|
"list_webhooks",
|
|
10208
10208
|
"List every webhook in the current sub-account. A webhook here is a workflow that fires a `webhook` action, so this also finds ones built by hand in the GHL UI. Returns the URL, HTTP method and triggering events for each. NOTE: GoHighLevel has no webhook REST API (it is Marketplace-OAuth only) \u2014 webhooks live on the workflow layer, which is why each one has a workflow ID and shows up in the client's Automation list. " + WEBHOOK_PAYLOAD_NOTE,
|
|
@@ -13688,11 +13688,407 @@ function registerBulkOperationTools(server2, client) {
|
|
|
13688
13688
|
|
|
13689
13689
|
// src/tools/account-export.ts
|
|
13690
13690
|
var import_zod46 = require("zod");
|
|
13691
|
+
|
|
13692
|
+
// src/contact-export.ts
|
|
13693
|
+
var CONTACT_PAGE_SIZE = 100;
|
|
13694
|
+
var DEFAULT_MAX_CONTACTS = 5e3;
|
|
13695
|
+
var MAX_PAGES = 1e3;
|
|
13696
|
+
function isComplete(reason) {
|
|
13697
|
+
return reason === "complete";
|
|
13698
|
+
}
|
|
13699
|
+
function stopReasonSentence(reason, fetched, cap) {
|
|
13700
|
+
switch (reason) {
|
|
13701
|
+
case "complete":
|
|
13702
|
+
return `All ${fetched} contacts were exported.`;
|
|
13703
|
+
case "cap-reached":
|
|
13704
|
+
return `INCOMPLETE: ${fetched} contacts were exported and the limit of ${cap} was reached. This account has more contacts than that. Run it again with a higher limit before treating this file as the full list.`;
|
|
13705
|
+
case "cursor-exhausted":
|
|
13706
|
+
return `INCOMPLETE: ${fetched} contacts were exported, then GoHighLevel stopped providing a way to ask for the next page. Treat this file as partial.`;
|
|
13707
|
+
case "cursor-stalled":
|
|
13708
|
+
return `INCOMPLETE: ${fetched} contacts were exported, then GoHighLevel began returning the same page repeatedly. The export stopped rather than loop. Treat this file as partial.`;
|
|
13709
|
+
case "page-ceiling":
|
|
13710
|
+
return `INCOMPLETE: ${fetched} contacts were exported and the internal page ceiling was reached. Treat this file as partial.`;
|
|
13711
|
+
}
|
|
13712
|
+
}
|
|
13691
13713
|
function delay2(ms) {
|
|
13692
13714
|
return new Promise((resolve8) => setTimeout(resolve8, ms));
|
|
13693
13715
|
}
|
|
13694
|
-
function
|
|
13695
|
-
const
|
|
13716
|
+
async function fetchAllContacts(client, locationId2, options = {}) {
|
|
13717
|
+
const cap = options.maxContacts ?? DEFAULT_MAX_CONTACTS;
|
|
13718
|
+
const pauseMs = options.pauseMs ?? 100;
|
|
13719
|
+
const rows = [];
|
|
13720
|
+
let startAfter;
|
|
13721
|
+
let startAfterId;
|
|
13722
|
+
let pages = 0;
|
|
13723
|
+
let indexTotal = null;
|
|
13724
|
+
let stopReason = "complete";
|
|
13725
|
+
while (true) {
|
|
13726
|
+
if (pages >= MAX_PAGES) {
|
|
13727
|
+
stopReason = "page-ceiling";
|
|
13728
|
+
break;
|
|
13729
|
+
}
|
|
13730
|
+
const raw = await client.get("/contacts/", {
|
|
13731
|
+
params: {
|
|
13732
|
+
locationId: locationId2,
|
|
13733
|
+
limit: CONTACT_PAGE_SIZE,
|
|
13734
|
+
// Both cursor halves or neither. Sending one alone re-serves page 1.
|
|
13735
|
+
startAfter,
|
|
13736
|
+
startAfterId
|
|
13737
|
+
}
|
|
13738
|
+
});
|
|
13739
|
+
const parsed = ContactSearchResponseSchema.parse(raw);
|
|
13740
|
+
pages += 1;
|
|
13741
|
+
if (pages === 1 && typeof parsed.meta?.total === "number") {
|
|
13742
|
+
indexTotal = parsed.meta.total;
|
|
13743
|
+
}
|
|
13744
|
+
if (parsed.contacts.length === 0) {
|
|
13745
|
+
stopReason = "complete";
|
|
13746
|
+
break;
|
|
13747
|
+
}
|
|
13748
|
+
for (const contact of parsed.contacts) {
|
|
13749
|
+
rows.push(contact);
|
|
13750
|
+
if (rows.length >= cap) break;
|
|
13751
|
+
}
|
|
13752
|
+
if (rows.length >= cap) {
|
|
13753
|
+
stopReason = "cap-reached";
|
|
13754
|
+
break;
|
|
13755
|
+
}
|
|
13756
|
+
const nextAfter = parsed.meta?.startAfter ?? void 0;
|
|
13757
|
+
const nextAfterId = parsed.meta?.startAfterId ?? void 0;
|
|
13758
|
+
if (nextAfter == null || !nextAfterId) {
|
|
13759
|
+
stopReason = "cursor-exhausted";
|
|
13760
|
+
break;
|
|
13761
|
+
}
|
|
13762
|
+
if (nextAfter === startAfter && nextAfterId === startAfterId) {
|
|
13763
|
+
stopReason = "cursor-stalled";
|
|
13764
|
+
break;
|
|
13765
|
+
}
|
|
13766
|
+
startAfter = nextAfter;
|
|
13767
|
+
startAfterId = nextAfterId;
|
|
13768
|
+
if (pauseMs > 0) await delay2(pauseMs);
|
|
13769
|
+
}
|
|
13770
|
+
return {
|
|
13771
|
+
rows,
|
|
13772
|
+
fetched: rows.length,
|
|
13773
|
+
pages,
|
|
13774
|
+
stopReason,
|
|
13775
|
+
complete: isComplete(stopReason),
|
|
13776
|
+
indexTotal
|
|
13777
|
+
};
|
|
13778
|
+
}
|
|
13779
|
+
var PREFERRED_COLUMNS = [
|
|
13780
|
+
"id",
|
|
13781
|
+
"firstName",
|
|
13782
|
+
"lastName",
|
|
13783
|
+
"name",
|
|
13784
|
+
"email",
|
|
13785
|
+
"phone",
|
|
13786
|
+
"companyName",
|
|
13787
|
+
"tags",
|
|
13788
|
+
"source",
|
|
13789
|
+
"type",
|
|
13790
|
+
"dateAdded",
|
|
13791
|
+
"dateUpdated",
|
|
13792
|
+
"address1",
|
|
13793
|
+
"city",
|
|
13794
|
+
"state",
|
|
13795
|
+
"postalCode",
|
|
13796
|
+
"country",
|
|
13797
|
+
"timezone",
|
|
13798
|
+
"dnd"
|
|
13799
|
+
];
|
|
13800
|
+
function neutralizeFormula(text) {
|
|
13801
|
+
return /^[=+\-@\t\r]/.test(text) ? `'${text}` : text;
|
|
13802
|
+
}
|
|
13803
|
+
function cell(value) {
|
|
13804
|
+
if (value === null || value === void 0) return "";
|
|
13805
|
+
const raw = typeof value === "string" ? value : typeof value === "number" || typeof value === "boolean" ? String(value) : JSON.stringify(value);
|
|
13806
|
+
const text = neutralizeFormula(raw);
|
|
13807
|
+
return /[",\n\r]/.test(text) ? `"${text.replace(/"/g, '""')}"` : text;
|
|
13808
|
+
}
|
|
13809
|
+
function contactsToCsv(rows) {
|
|
13810
|
+
if (rows.length === 0) return "";
|
|
13811
|
+
const seen = /* @__PURE__ */ new Set();
|
|
13812
|
+
for (const row of rows) for (const key of Object.keys(row)) seen.add(key);
|
|
13813
|
+
const preferred = PREFERRED_COLUMNS.filter((c) => seen.has(c));
|
|
13814
|
+
const rest = [...seen].filter((c) => !preferred.includes(c)).sort();
|
|
13815
|
+
const columns = [...preferred, ...rest];
|
|
13816
|
+
const lines = [columns.join(",")];
|
|
13817
|
+
for (const row of rows) lines.push(columns.map((c) => cell(row[c])).join(","));
|
|
13818
|
+
return lines.join("\n");
|
|
13819
|
+
}
|
|
13820
|
+
|
|
13821
|
+
// src/asset-ownership.ts
|
|
13822
|
+
var OWNERSHIP_PREFIX = "Offboarding Owner: ";
|
|
13823
|
+
var OWNER_KINDS = ["client", "agency", "borrowed", "third-party"];
|
|
13824
|
+
var OWNER_MEANING = {
|
|
13825
|
+
client: "The client owns this. It stays with them and they keep paying for it.",
|
|
13826
|
+
agency: "The agency owns this. It leaves with the agency unless it is sold or transferred.",
|
|
13827
|
+
borrowed: "Borrowed for the engagement. It must be returned, disconnected, or replaced at exit.",
|
|
13828
|
+
"third-party": "Owned by an outside vendor. Transfer depends on that vendor's own process."
|
|
13829
|
+
};
|
|
13830
|
+
function readCustomValues(raw) {
|
|
13831
|
+
const box = raw && typeof raw === "object" ? raw : {};
|
|
13832
|
+
const list = Array.isArray(box.customValues) ? box.customValues : [];
|
|
13833
|
+
return list.flatMap((entry) => {
|
|
13834
|
+
if (!entry || typeof entry !== "object") return [];
|
|
13835
|
+
const row = entry;
|
|
13836
|
+
if (typeof row.id !== "string" || typeof row.name !== "string") return [];
|
|
13837
|
+
return [{ id: row.id, name: row.name, value: typeof row.value === "string" ? row.value : "" }];
|
|
13838
|
+
});
|
|
13839
|
+
}
|
|
13840
|
+
function isOwnerKind(value) {
|
|
13841
|
+
return OWNER_KINDS.includes(value);
|
|
13842
|
+
}
|
|
13843
|
+
function encodeOwnership(owner, note) {
|
|
13844
|
+
return note && note.trim() ? `${owner} | ${note.trim()}` : owner;
|
|
13845
|
+
}
|
|
13846
|
+
function decodeOwnership(value) {
|
|
13847
|
+
const [head, ...rest] = value.split("|");
|
|
13848
|
+
const owner = head.trim();
|
|
13849
|
+
const note = rest.join("|").trim();
|
|
13850
|
+
return { owner: isOwnerKind(owner) ? owner : null, note: note || void 0 };
|
|
13851
|
+
}
|
|
13852
|
+
async function readOwnership(client, locationId2) {
|
|
13853
|
+
const values = readCustomValues(await client.get(`/locations/${locationId2}/customValues`));
|
|
13854
|
+
const records = [];
|
|
13855
|
+
const unreadable = [];
|
|
13856
|
+
for (const value of values) {
|
|
13857
|
+
if (!value.name.startsWith(OWNERSHIP_PREFIX)) continue;
|
|
13858
|
+
const asset = value.name.slice(OWNERSHIP_PREFIX.length).trim();
|
|
13859
|
+
const { owner, note } = decodeOwnership(value.value);
|
|
13860
|
+
if (!owner) {
|
|
13861
|
+
unreadable.push(`${asset}: "${value.value}"`);
|
|
13862
|
+
continue;
|
|
13863
|
+
}
|
|
13864
|
+
records.push({ asset, owner, note, customValueId: value.id });
|
|
13865
|
+
}
|
|
13866
|
+
return { records, unreadable };
|
|
13867
|
+
}
|
|
13868
|
+
async function writeOwnership(client, locationId2, entries) {
|
|
13869
|
+
const existing = readCustomValues(await client.get(`/locations/${locationId2}/customValues`));
|
|
13870
|
+
const results = [];
|
|
13871
|
+
for (const entry of entries) {
|
|
13872
|
+
const asset = entry.asset.trim();
|
|
13873
|
+
const name = `${OWNERSHIP_PREFIX}${asset}`;
|
|
13874
|
+
const matches = existing.filter((value) => value.name === name);
|
|
13875
|
+
if (matches.length > 1) {
|
|
13876
|
+
results.push({
|
|
13877
|
+
asset,
|
|
13878
|
+
action: "halted",
|
|
13879
|
+
reason: `${matches.length} custom values are named "${name}". Updating by name cannot tell them apart, so nothing was changed. Delete the extras in GHL, then run this again.`
|
|
13880
|
+
});
|
|
13881
|
+
continue;
|
|
13882
|
+
}
|
|
13883
|
+
const body = { name, value: encodeOwnership(entry.owner, entry.note) };
|
|
13884
|
+
if (matches.length === 1) {
|
|
13885
|
+
if (!matches[0].id) {
|
|
13886
|
+
results.push({
|
|
13887
|
+
asset,
|
|
13888
|
+
action: "halted",
|
|
13889
|
+
reason: `"${name}" was created earlier in this request but GoHighLevel did not return its id, so it cannot be updated safely. Run this asset again on its own.`
|
|
13890
|
+
});
|
|
13891
|
+
continue;
|
|
13892
|
+
}
|
|
13893
|
+
await client.put(`/locations/${locationId2}/customValues/${matches[0].id}`, { body });
|
|
13894
|
+
results.push({ asset, action: "updated", customValueId: matches[0].id });
|
|
13895
|
+
continue;
|
|
13896
|
+
}
|
|
13897
|
+
const created = await client.post(`/locations/${locationId2}/customValues`, { body });
|
|
13898
|
+
const createdId = readCreatedId(created);
|
|
13899
|
+
existing.push({ id: createdId ?? "", name, value: body.value });
|
|
13900
|
+
results.push({ asset, action: "created", customValueId: createdId });
|
|
13901
|
+
}
|
|
13902
|
+
return results;
|
|
13903
|
+
}
|
|
13904
|
+
function readCreatedId(raw) {
|
|
13905
|
+
if (!raw || typeof raw !== "object") return void 0;
|
|
13906
|
+
const box = raw;
|
|
13907
|
+
if (typeof box.id === "string") return box.id;
|
|
13908
|
+
const nested = box.customValue;
|
|
13909
|
+
if (nested && typeof nested === "object") {
|
|
13910
|
+
const inner = nested.id;
|
|
13911
|
+
if (typeof inner === "string") return inner;
|
|
13912
|
+
}
|
|
13913
|
+
return void 0;
|
|
13914
|
+
}
|
|
13915
|
+
|
|
13916
|
+
// src/handoff-doc.ts
|
|
13917
|
+
function sectionItems(section2) {
|
|
13918
|
+
return Array.isArray(section2) ? section2 : [];
|
|
13919
|
+
}
|
|
13920
|
+
function bullet(lines) {
|
|
13921
|
+
return lines.length ? lines.map((l) => `- ${l}`).join("\n") : "- None found.";
|
|
13922
|
+
}
|
|
13923
|
+
function plainReason(reason) {
|
|
13924
|
+
const withoutBody = reason.split(/[{\n]/)[0];
|
|
13925
|
+
const withoutRoute = withoutBody.replace(/:?\s*(GET|POST|PUT|DELETE)\s+\/\S*/g, "");
|
|
13926
|
+
const cleaned = withoutRoute.replace(/\s+/g, " ").replace(/[:\s]+$/, "").trim();
|
|
13927
|
+
return cleaned || "the account did not return this section";
|
|
13928
|
+
}
|
|
13929
|
+
function inventoryTable(inventory) {
|
|
13930
|
+
const rows = Object.entries(inventory).map(([section2, data]) => {
|
|
13931
|
+
if (data.unavailable) return `| ${section2} | not read | ${plainReason(data.unavailable)} |`;
|
|
13932
|
+
const sample = data.names.slice(0, 8).join(", ");
|
|
13933
|
+
const more = data.names.length > 8 ? `, and ${data.names.length - 8} more` : "";
|
|
13934
|
+
return `| ${section2} | ${data.count ?? data.names.length} | ${sample || "none"}${more} |`;
|
|
13935
|
+
});
|
|
13936
|
+
return ["| What | How many | Which ones |", "| --- | --- | --- |", ...rows].join("\n");
|
|
13937
|
+
}
|
|
13938
|
+
function sectionList(section2, render) {
|
|
13939
|
+
if (!Array.isArray(section2)) {
|
|
13940
|
+
return `**Not read.** ${plainReason(section2.unavailable)}. This section is unknown, which is NOT the same as empty. Check it by hand before you rely on this document.`;
|
|
13941
|
+
}
|
|
13942
|
+
return bullet(section2.map(render));
|
|
13943
|
+
}
|
|
13944
|
+
function buildHandoffDoc(input) {
|
|
13945
|
+
const {
|
|
13946
|
+
accountName,
|
|
13947
|
+
locationId: locationId2,
|
|
13948
|
+
generatedAt,
|
|
13949
|
+
contacts,
|
|
13950
|
+
inventory,
|
|
13951
|
+
users,
|
|
13952
|
+
phoneNumbers,
|
|
13953
|
+
socialAccounts,
|
|
13954
|
+
webhooks,
|
|
13955
|
+
webhookScanPartial,
|
|
13956
|
+
ownership
|
|
13957
|
+
} = input;
|
|
13958
|
+
const ownedBy = (kind) => ownership.recorded.filter((r) => r.owner === kind);
|
|
13959
|
+
const ownershipSection = ownership.recorded.length ? ["client", "agency", "borrowed", "third-party"].map((kind) => {
|
|
13960
|
+
const items = ownedBy(kind);
|
|
13961
|
+
if (!items.length) return "";
|
|
13962
|
+
return `**${kind}** ${OWNER_MEANING[kind]}
|
|
13963
|
+
|
|
13964
|
+
${bullet(
|
|
13965
|
+
items.map((i) => i.note ? `${i.asset} (${i.note})` : i.asset)
|
|
13966
|
+
)}`;
|
|
13967
|
+
}).filter(Boolean).join("\n\n") : "Nothing has been recorded yet. Every asset below is unanswered.";
|
|
13968
|
+
const unknownSection = ownership.unknown.length ? `These exist in the account but nobody has said who owns them. Answer them once and they stay answered:
|
|
13969
|
+
|
|
13970
|
+
${bullet(ownership.unknown)}` : "Every asset found in this account has a recorded owner.";
|
|
13971
|
+
const webhookSection = sectionList(webhooks, (w) => `${w.name} sends to ${w.url}${w.events.length ? ` when: ${w.events.join(", ")}` : ""}`) + (webhookScanPartial ? `
|
|
13972
|
+
|
|
13973
|
+
**This list may be incomplete.** ${webhookScanPartial}` : "");
|
|
13974
|
+
return `# Account handoff: ${accountName}
|
|
13975
|
+
|
|
13976
|
+
Prepared ${generatedAt}. GoHighLevel account ID ${locationId2}.
|
|
13977
|
+
|
|
13978
|
+
This document describes what is inside this account, who can get into it, and
|
|
13979
|
+
what has to change hands. It was generated from the live account, not from
|
|
13980
|
+
memory.
|
|
13981
|
+
|
|
13982
|
+
## Read this first
|
|
13983
|
+
|
|
13984
|
+
${contacts.complete ? "" : '**The contact export in this package is incomplete. See "Contacts" below before you rely on it.**\n\n'}Three things this document deliberately does not contain:
|
|
13985
|
+
|
|
13986
|
+
1. **No passwords or API keys.** GoHighLevel does not hand out key values to any
|
|
13987
|
+
tool, including this one. What you get instead is a list of every place a key
|
|
13988
|
+
exists, so you can rotate them. That list is at the end.
|
|
13989
|
+
2. **No opinion about who owns what**, beyond what somebody actually recorded.
|
|
13990
|
+
Anything unanswered is listed as unanswered.
|
|
13991
|
+
3. **No guesses about what is missing.** If a section could not be read, it says
|
|
13992
|
+
so and why, rather than showing zero.
|
|
13993
|
+
|
|
13994
|
+
## Contacts
|
|
13995
|
+
|
|
13996
|
+
${contacts.sentence}${contacts.indexTotal !== null ? `
|
|
13997
|
+
|
|
13998
|
+
GoHighLevel's own index reports **${contacts.indexTotal}** contacts for this account. This export contains **${contacts.exported}**. These two numbers are counted differently and GoHighLevel's index updates on a delay, so a small difference is normal and does not by itself mean the export is short.` : ""}
|
|
13999
|
+
|
|
14000
|
+
## What is in the account
|
|
14001
|
+
|
|
14002
|
+
${inventoryTable(inventory)}
|
|
14003
|
+
|
|
14004
|
+
## Who can get in
|
|
14005
|
+
|
|
14006
|
+
### People with logins
|
|
14007
|
+
|
|
14008
|
+
${sectionList(users, (u) => `${u.name} (${u.email})${u.roles ? `, ${u.roles}` : ""}`)}
|
|
14009
|
+
|
|
14010
|
+
Every person above can log into this account right now. Remove the ones who
|
|
14011
|
+
should not be able to, on the day the relationship ends, not later.
|
|
14012
|
+
|
|
14013
|
+
### Phone numbers
|
|
14014
|
+
|
|
14015
|
+
${sectionList(phoneNumbers, (p) => p.label ? `${p.number} (${p.label})` : p.number)}
|
|
14016
|
+
|
|
14017
|
+
Numbers are rented from GoHighLevel and stop working if the account lapses. If
|
|
14018
|
+
the client needs to keep a number, it has to be ported out before then, and
|
|
14019
|
+
porting takes days to weeks.
|
|
14020
|
+
|
|
14021
|
+
### Connected accounts
|
|
14022
|
+
|
|
14023
|
+
${sectionList(socialAccounts, (s) => `${s.platform}: ${s.name}`)}
|
|
14024
|
+
|
|
14025
|
+
These connections were authorised by a person. If that person leaves, the
|
|
14026
|
+
connection can break even though nothing in GoHighLevel changed.
|
|
14027
|
+
|
|
14028
|
+
### Outbound webhooks
|
|
14029
|
+
|
|
14030
|
+
${webhookSection}
|
|
14031
|
+
|
|
14032
|
+
Each of these sends data out of this account to somewhere else. Confirm the
|
|
14033
|
+
receiving system should still be receiving it.
|
|
14034
|
+
|
|
14035
|
+
## Who owns what
|
|
14036
|
+
|
|
14037
|
+
${ownershipSection}
|
|
14038
|
+
|
|
14039
|
+
### Not yet answered
|
|
14040
|
+
|
|
14041
|
+
${unknownSection}
|
|
14042
|
+
${ownership.unreadable.length ? `
|
|
14043
|
+
### Recorded but unreadable
|
|
14044
|
+
|
|
14045
|
+
Somebody edited these by hand and the answer no longer parses. They need a human:
|
|
14046
|
+
|
|
14047
|
+
${bullet(ownership.unreadable)}
|
|
14048
|
+
` : ""}
|
|
14049
|
+
## Rotate these on the way out
|
|
14050
|
+
|
|
14051
|
+
Key values cannot be read by any tool, so this is a checklist of places to go
|
|
14052
|
+
and change something, not a list of secrets.
|
|
14053
|
+
|
|
14054
|
+
- The GoHighLevel Private Integration key for this account. Anyone holding the
|
|
14055
|
+
old one keeps full access until it is deleted.
|
|
14056
|
+
- Any Firebase or agency-level credential used to reach this account.
|
|
14057
|
+
- Every connected account listed above, if the departing party authorised it.
|
|
14058
|
+
- Every webhook destination above, if it authenticates with a shared secret.
|
|
14059
|
+
- Any payment processor, email sending domain, or advertising account attached
|
|
14060
|
+
to this business but living outside GoHighLevel.
|
|
14061
|
+
|
|
14062
|
+
## What to do next
|
|
14063
|
+
|
|
14064
|
+
1. Save the contact export alongside this document.
|
|
14065
|
+
2. Answer anything under "Not yet answered" and re-run the kit so the answers
|
|
14066
|
+
are stored in the account itself.
|
|
14067
|
+
3. Work down the rotation list above.
|
|
14068
|
+
4. Remove the logins that should no longer exist.
|
|
14069
|
+
`;
|
|
14070
|
+
}
|
|
14071
|
+
|
|
14072
|
+
// src/tools/account-export.ts
|
|
14073
|
+
function delay3(ms) {
|
|
14074
|
+
return new Promise((resolve8) => setTimeout(resolve8, ms));
|
|
14075
|
+
}
|
|
14076
|
+
function builderSectionAvailability(builderLocationId, requestedLocationId) {
|
|
14077
|
+
if (!builderLocationId) {
|
|
14078
|
+
return {
|
|
14079
|
+
usable: false,
|
|
14080
|
+
reason: "Not read: the workflow builder is not connected. Run enable_workflow_builder to add Firebase credentials, then export again."
|
|
14081
|
+
};
|
|
14082
|
+
}
|
|
14083
|
+
if (builderLocationId !== requestedLocationId) {
|
|
14084
|
+
return {
|
|
14085
|
+
usable: false,
|
|
14086
|
+
reason: `Not read: the workflow builder is connected to a DIFFERENT sub-account (${builderLocationId}), not the one being exported (${requestedLocationId}). Reading it would describe the wrong account. Run switch_location to ${requestedLocationId} and export again.`
|
|
14087
|
+
};
|
|
14088
|
+
}
|
|
14089
|
+
return { usable: true };
|
|
14090
|
+
}
|
|
14091
|
+
function registerAccountExportTools(server2, client, registry2, builderClient) {
|
|
13696
14092
|
server2.tool(
|
|
13697
14093
|
"export_account",
|
|
13698
14094
|
"Export a complete inventory of the GHL sub-account: location info, contacts (count + sample), pipelines with stages, workflows (with full actions if builder auth is configured), funnels with pages, forms, custom fields, custom values, tags, calendars, and users. Returns a comprehensive JSON report for auditing or backup.",
|
|
@@ -13709,10 +14105,10 @@ function registerAccountExportTools(server2, client, registry2) {
|
|
|
13709
14105
|
};
|
|
13710
14106
|
try {
|
|
13711
14107
|
report.location = await client.get(`/locations/${locId}`);
|
|
13712
|
-
} catch {
|
|
13713
|
-
report.location =
|
|
14108
|
+
} catch (e) {
|
|
14109
|
+
report.location = `Error fetching: ${errorMessage(e)}`;
|
|
13714
14110
|
}
|
|
13715
|
-
await
|
|
14111
|
+
await delay3(100);
|
|
13716
14112
|
try {
|
|
13717
14113
|
const contacts = await client.get("/contacts/", {
|
|
13718
14114
|
params: { locationId: locId, limit: includeContacts ? 100 : 1 }
|
|
@@ -13724,36 +14120,38 @@ function registerAccountExportTools(server2, client, registry2) {
|
|
|
13724
14120
|
total: typeof meta.total === "number" ? meta.total : contactList.length,
|
|
13725
14121
|
sample: includeContacts ? contactList : contactList.slice(0, 1)
|
|
13726
14122
|
};
|
|
13727
|
-
} catch {
|
|
13728
|
-
report.contacts =
|
|
14123
|
+
} catch (e) {
|
|
14124
|
+
report.contacts = `Error fetching: ${errorMessage(e)}`;
|
|
13729
14125
|
}
|
|
13730
|
-
await
|
|
14126
|
+
await delay3(100);
|
|
13731
14127
|
try {
|
|
13732
14128
|
report.pipelines = await client.get("/opportunities/pipelines", {
|
|
13733
14129
|
params: { locationId: locId }
|
|
13734
14130
|
});
|
|
13735
|
-
} catch {
|
|
13736
|
-
report.pipelines =
|
|
14131
|
+
} catch (e) {
|
|
14132
|
+
report.pipelines = `Error fetching: ${errorMessage(e)}`;
|
|
13737
14133
|
}
|
|
13738
|
-
await
|
|
14134
|
+
await delay3(100);
|
|
13739
14135
|
try {
|
|
13740
14136
|
report.workflows = await client.get("/workflows/", {
|
|
13741
14137
|
params: { locationId: locId }
|
|
13742
14138
|
});
|
|
13743
|
-
} catch {
|
|
13744
|
-
report.workflows =
|
|
14139
|
+
} catch (e) {
|
|
14140
|
+
report.workflows = `Error fetching: ${errorMessage(e)}`;
|
|
13745
14141
|
}
|
|
13746
|
-
await
|
|
13747
|
-
|
|
14142
|
+
await delay3(100);
|
|
14143
|
+
const builderUse = builderSectionAvailability(builderClient?.locationId, locId);
|
|
14144
|
+
if (builderUse.usable && builderClient) {
|
|
13748
14145
|
try {
|
|
13749
|
-
|
|
13750
|
-
|
|
13751
|
-
|
|
13752
|
-
report.workflowsFull = "Error fetching (builder auth may have expired)";
|
|
14146
|
+
report.workflowsFull = await builderClient.listWorkflows(50, 0);
|
|
14147
|
+
} catch (e) {
|
|
14148
|
+
report.workflowsFull = `Error fetching: ${errorMessage(e)}`;
|
|
13753
14149
|
}
|
|
13754
|
-
await
|
|
14150
|
+
await delay3(100);
|
|
14151
|
+
} else {
|
|
14152
|
+
report.workflowsFull = builderUse.reason;
|
|
13755
14153
|
}
|
|
13756
|
-
if (builderClient) {
|
|
14154
|
+
if (builderUse.usable && builderClient) {
|
|
13757
14155
|
try {
|
|
13758
14156
|
const headers = await builderClient.buildHeaders();
|
|
13759
14157
|
const res = await fetch(
|
|
@@ -13761,60 +14159,60 @@ function registerAccountExportTools(server2, client, registry2) {
|
|
|
13761
14159
|
{ headers }
|
|
13762
14160
|
);
|
|
13763
14161
|
if (res.ok) report.funnels = await res.json();
|
|
13764
|
-
else report.funnels =
|
|
13765
|
-
} catch {
|
|
13766
|
-
report.funnels =
|
|
14162
|
+
else report.funnels = `Error fetching: builder returned HTTP ${res.status}`;
|
|
14163
|
+
} catch (e) {
|
|
14164
|
+
report.funnels = `Error fetching: ${errorMessage(e)}`;
|
|
13767
14165
|
}
|
|
13768
|
-
await
|
|
14166
|
+
await delay3(100);
|
|
13769
14167
|
} else {
|
|
13770
14168
|
try {
|
|
13771
14169
|
report.funnels = await client.get("/funnels/", {
|
|
13772
14170
|
params: { locationId: locId }
|
|
13773
14171
|
});
|
|
13774
|
-
} catch {
|
|
13775
|
-
report.funnels =
|
|
14172
|
+
} catch (e) {
|
|
14173
|
+
report.funnels = `Error fetching: ${errorMessage(e)}`;
|
|
13776
14174
|
}
|
|
13777
14175
|
}
|
|
13778
14176
|
try {
|
|
13779
14177
|
report.forms = await client.get("/forms/", {
|
|
13780
14178
|
params: { locationId: locId }
|
|
13781
14179
|
});
|
|
13782
|
-
} catch {
|
|
13783
|
-
report.forms =
|
|
14180
|
+
} catch (e) {
|
|
14181
|
+
report.forms = `Error fetching: ${errorMessage(e)}`;
|
|
13784
14182
|
}
|
|
13785
|
-
await
|
|
14183
|
+
await delay3(100);
|
|
13786
14184
|
try {
|
|
13787
14185
|
report.customFields = await client.get("/locations/" + locId + "/customFields");
|
|
13788
|
-
} catch {
|
|
13789
|
-
report.customFields =
|
|
14186
|
+
} catch (e) {
|
|
14187
|
+
report.customFields = `Error fetching: ${errorMessage(e)}`;
|
|
13790
14188
|
}
|
|
13791
|
-
await
|
|
14189
|
+
await delay3(100);
|
|
13792
14190
|
try {
|
|
13793
14191
|
report.customValues = await client.get("/locations/" + locId + "/customValues");
|
|
13794
|
-
} catch {
|
|
13795
|
-
report.customValues =
|
|
14192
|
+
} catch (e) {
|
|
14193
|
+
report.customValues = `Error fetching: ${errorMessage(e)}`;
|
|
13796
14194
|
}
|
|
13797
|
-
await
|
|
14195
|
+
await delay3(100);
|
|
13798
14196
|
try {
|
|
13799
14197
|
report.tags = await client.get("/locations/" + locId + "/tags");
|
|
13800
|
-
} catch {
|
|
13801
|
-
report.tags =
|
|
14198
|
+
} catch (e) {
|
|
14199
|
+
report.tags = `Error fetching: ${errorMessage(e)}`;
|
|
13802
14200
|
}
|
|
13803
|
-
await
|
|
14201
|
+
await delay3(100);
|
|
13804
14202
|
try {
|
|
13805
14203
|
report.calendars = await client.get("/calendars/", {
|
|
13806
14204
|
params: { locationId: locId }
|
|
13807
14205
|
});
|
|
13808
|
-
} catch {
|
|
13809
|
-
report.calendars =
|
|
14206
|
+
} catch (e) {
|
|
14207
|
+
report.calendars = `Error fetching: ${errorMessage(e)}`;
|
|
13810
14208
|
}
|
|
13811
|
-
await
|
|
14209
|
+
await delay3(100);
|
|
13812
14210
|
try {
|
|
13813
14211
|
report.users = await client.get("/users/", {
|
|
13814
14212
|
params: { locationId: locId }
|
|
13815
14213
|
});
|
|
13816
|
-
} catch {
|
|
13817
|
-
report.users =
|
|
14214
|
+
} catch (e) {
|
|
14215
|
+
report.users = `Error fetching: ${errorMessage(e)}`;
|
|
13818
14216
|
}
|
|
13819
14217
|
return jsonResponse(report);
|
|
13820
14218
|
} catch (error) {
|
|
@@ -13854,13 +14252,13 @@ function registerAccountExportTools(server2, client, registry2) {
|
|
|
13854
14252
|
const fetchData = async (locId) => {
|
|
13855
14253
|
const data = {};
|
|
13856
14254
|
data.pipelines = await read(() => client.get("/opportunities/pipelines", { params: { locationId: locId } }));
|
|
13857
|
-
await
|
|
14255
|
+
await delay3(100);
|
|
13858
14256
|
data.workflows = await read(() => client.get("/workflows/", { params: { locationId: locId } }));
|
|
13859
|
-
await
|
|
14257
|
+
await delay3(100);
|
|
13860
14258
|
data.customFields = await read(() => client.get(`/locations/${locId}/customFields`));
|
|
13861
|
-
await
|
|
14259
|
+
await delay3(100);
|
|
13862
14260
|
data.tags = await read(() => client.get(`/locations/${locId}/tags`));
|
|
13863
|
-
await
|
|
14261
|
+
await delay3(100);
|
|
13864
14262
|
data.forms = await read(() => client.get("/forms/", { params: { locationId: locId } }));
|
|
13865
14263
|
return data;
|
|
13866
14264
|
};
|
|
@@ -13893,6 +14291,270 @@ function registerAccountExportTools(server2, client, registry2) {
|
|
|
13893
14291
|
}
|
|
13894
14292
|
}
|
|
13895
14293
|
);
|
|
14294
|
+
server2.tool(
|
|
14295
|
+
"build_offboarding_kit",
|
|
14296
|
+
"Assemble the complete exit package for a sub-account: a full contact export, an inventory of everything the account contains, an access map of every person and connected account that can reach it, the recorded owner of each asset, a key-rotation checklist, and a plain-language handoff document the next operator can follow. Read-only: it changes nothing. locationId is REQUIRED and never defaults, because exporting the wrong client's account is the one mistake this tool must not be able to make. Assets nobody has recorded an owner for come back under ownership.unanswered \u2014 answer those with record_asset_ownership and they stay answered. Key VALUES are never included: GoHighLevel does not expose them to any tool, so the kit lists where each key lives instead.",
|
|
14297
|
+
{
|
|
14298
|
+
locationId: import_zod46.z.string().describe("Sub-account to build the kit for. REQUIRED \u2014 this tool never falls back to the active account."),
|
|
14299
|
+
contacts: import_zod46.z.enum(["summary", "csv"]).optional().describe(
|
|
14300
|
+
"'summary' (default) returns contact counts only. 'csv' also returns the full contact export as CSV text, which can be large \u2014 ask for it when you are ready to save the file."
|
|
14301
|
+
),
|
|
14302
|
+
maxContacts: import_zod46.z.number().int().positive().optional().describe(`Ceiling on contacts read. Defaults to ${DEFAULT_MAX_CONTACTS}. If the ceiling is hit, the kit says so in the handoff document itself rather than quietly returning a short list.`)
|
|
14303
|
+
},
|
|
14304
|
+
async ({ locationId: locationId2, contacts, maxContacts }) => {
|
|
14305
|
+
const originalKey = client.getApiKey();
|
|
14306
|
+
const registered = registry2?.getToken(locationId2);
|
|
14307
|
+
const usedRegisteredKey = Boolean(registered?.apiKey && registered.apiKey !== originalKey);
|
|
14308
|
+
if (usedRegisteredKey && registered?.apiKey) client.setApiKey(registered.apiKey);
|
|
14309
|
+
try {
|
|
14310
|
+
const generatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
14311
|
+
let accountName = locationId2;
|
|
14312
|
+
let location = null;
|
|
14313
|
+
try {
|
|
14314
|
+
location = await client.get(`/locations/${locationId2}`);
|
|
14315
|
+
const box = location && typeof location === "object" ? location : {};
|
|
14316
|
+
const inner = box.location && typeof box.location === "object" ? box.location : box;
|
|
14317
|
+
if (typeof inner.name === "string" && inner.name) accountName = inner.name;
|
|
14318
|
+
} catch (e) {
|
|
14319
|
+
location = `Error fetching: ${errorMessage(e)}`;
|
|
14320
|
+
}
|
|
14321
|
+
await delay3(100);
|
|
14322
|
+
const inventory = {};
|
|
14323
|
+
const section2 = async (label, fn) => {
|
|
14324
|
+
try {
|
|
14325
|
+
const names = extractNames(await fn());
|
|
14326
|
+
inventory[label] = { count: names.length, names };
|
|
14327
|
+
} catch (e) {
|
|
14328
|
+
inventory[label] = { count: null, names: [], unavailable: errorMessage(e) };
|
|
14329
|
+
}
|
|
14330
|
+
await delay3(100);
|
|
14331
|
+
};
|
|
14332
|
+
await section2("pipelines", () => client.get("/opportunities/pipelines", { params: { locationId: locationId2 } }));
|
|
14333
|
+
await section2("workflows", () => client.get("/workflows/", { params: { locationId: locationId2 } }));
|
|
14334
|
+
await section2("funnels", () => client.get("/funnels/", { params: { locationId: locationId2 } }));
|
|
14335
|
+
await section2("forms", () => client.get("/forms/", { params: { locationId: locationId2 } }));
|
|
14336
|
+
await section2("calendars", () => client.get("/calendars/", { params: { locationId: locationId2 } }));
|
|
14337
|
+
await section2("customFields", () => client.get(`/locations/${locationId2}/customFields`));
|
|
14338
|
+
await section2("customValues", () => client.get(`/locations/${locationId2}/customValues`));
|
|
14339
|
+
await section2("tags", () => client.get(`/locations/${locationId2}/tags`));
|
|
14340
|
+
const users = await readUsers(client, locationId2);
|
|
14341
|
+
await delay3(100);
|
|
14342
|
+
const phoneNumbers = await readPhoneNumbers(client, locationId2);
|
|
14343
|
+
await delay3(100);
|
|
14344
|
+
const socialAccounts = await readSocialAccounts(client, locationId2);
|
|
14345
|
+
await delay3(100);
|
|
14346
|
+
const WEBHOOK_SCAN_LIMIT = 50;
|
|
14347
|
+
const builderUse = builderSectionAvailability(builderClient?.locationId, locationId2);
|
|
14348
|
+
let webhooks;
|
|
14349
|
+
let webhookScanPartial;
|
|
14350
|
+
if (builderUse.usable && builderClient) {
|
|
14351
|
+
try {
|
|
14352
|
+
const scan = await loadWebhooks(builderClient, WEBHOOK_SCAN_LIMIT);
|
|
14353
|
+
const stillOurs = builderSectionAvailability(builderClient.locationId, locationId2);
|
|
14354
|
+
if (!stillOurs.usable) {
|
|
14355
|
+
webhooks = { unavailable: `The active account changed while webhooks were being read, so the result was discarded. ${stillOurs.reason ?? ""}` };
|
|
14356
|
+
} else {
|
|
14357
|
+
webhooks = scan.webhooks.map((w) => ({
|
|
14358
|
+
name: w.name,
|
|
14359
|
+
url: w.actions[0]?.url ?? "",
|
|
14360
|
+
events: w.events
|
|
14361
|
+
}));
|
|
14362
|
+
if (scan.scanned >= WEBHOOK_SCAN_LIMIT) {
|
|
14363
|
+
webhookScanPartial = `Only the first ${WEBHOOK_SCAN_LIMIT} workflows were checked and this account has at least that many, so a webhook built into a later workflow would not appear here. Check the account's Automation list by hand.`;
|
|
14364
|
+
}
|
|
14365
|
+
}
|
|
14366
|
+
} catch (e) {
|
|
14367
|
+
webhooks = { unavailable: `Error reading webhooks: ${errorMessage(e)}` };
|
|
14368
|
+
}
|
|
14369
|
+
} else {
|
|
14370
|
+
webhooks = { unavailable: builderUse.reason ?? "Not read." };
|
|
14371
|
+
}
|
|
14372
|
+
let ownershipRecords = { records: [], unreadable: [] };
|
|
14373
|
+
let ownershipError;
|
|
14374
|
+
try {
|
|
14375
|
+
ownershipRecords = await readOwnership(client, locationId2);
|
|
14376
|
+
} catch (e) {
|
|
14377
|
+
ownershipError = errorMessage(e);
|
|
14378
|
+
}
|
|
14379
|
+
const discovered = discoverAssets({ phoneNumbers, socialAccounts, inventory });
|
|
14380
|
+
const answered = new Set(ownershipRecords.records.map((r) => r.asset.toLowerCase()));
|
|
14381
|
+
const unanswered = discovered.filter((asset) => !answered.has(asset.toLowerCase()));
|
|
14382
|
+
const cap = maxContacts ?? DEFAULT_MAX_CONTACTS;
|
|
14383
|
+
let contactResult;
|
|
14384
|
+
let contactSentence;
|
|
14385
|
+
try {
|
|
14386
|
+
contactResult = await fetchAllContacts(client, locationId2, { maxContacts: cap });
|
|
14387
|
+
contactSentence = stopReasonSentence(contactResult.stopReason, contactResult.fetched, cap);
|
|
14388
|
+
} catch (e) {
|
|
14389
|
+
contactResult = { rows: [], fetched: 0, pages: 0, stopReason: "cursor-exhausted", complete: false, indexTotal: null };
|
|
14390
|
+
contactSentence = `INCOMPLETE: the contact export failed and returned nothing. Reason: ${errorMessage(e)}`;
|
|
14391
|
+
}
|
|
14392
|
+
const handoffDoc = buildHandoffDoc({
|
|
14393
|
+
accountName,
|
|
14394
|
+
locationId: locationId2,
|
|
14395
|
+
generatedAt,
|
|
14396
|
+
contacts: {
|
|
14397
|
+
exported: contactResult.fetched,
|
|
14398
|
+
indexTotal: contactResult.indexTotal,
|
|
14399
|
+
complete: contactResult.complete,
|
|
14400
|
+
sentence: contactSentence
|
|
14401
|
+
},
|
|
14402
|
+
inventory,
|
|
14403
|
+
users,
|
|
14404
|
+
phoneNumbers,
|
|
14405
|
+
socialAccounts,
|
|
14406
|
+
webhooks,
|
|
14407
|
+
webhookScanPartial,
|
|
14408
|
+
ownership: {
|
|
14409
|
+
recorded: ownershipRecords.records,
|
|
14410
|
+
unknown: unanswered,
|
|
14411
|
+
unreadable: ownershipRecords.unreadable
|
|
14412
|
+
}
|
|
14413
|
+
});
|
|
14414
|
+
return jsonResponse({
|
|
14415
|
+
account: { locationId: locationId2, name: accountName, generatedAt, usedRegisteredKey },
|
|
14416
|
+
contacts: {
|
|
14417
|
+
exported: contactResult.fetched,
|
|
14418
|
+
ghlIndexTotal: contactResult.indexTotal,
|
|
14419
|
+
complete: contactResult.complete,
|
|
14420
|
+
stopReason: contactResult.stopReason,
|
|
14421
|
+
pagesRead: contactResult.pages,
|
|
14422
|
+
statement: contactSentence,
|
|
14423
|
+
...contacts === "csv" ? { csv: contactsToCsv(contactResult.rows) } : {
|
|
14424
|
+
csvNote: "Run again with contacts:'csv' to get the full export as CSV text to save."
|
|
14425
|
+
}
|
|
14426
|
+
},
|
|
14427
|
+
inventory,
|
|
14428
|
+
accessMap: {
|
|
14429
|
+
users,
|
|
14430
|
+
phoneNumbers,
|
|
14431
|
+
socialAccounts,
|
|
14432
|
+
webhooks,
|
|
14433
|
+
...webhookScanPartial ? { webhookScanPartial } : {}
|
|
14434
|
+
},
|
|
14435
|
+
ownership: {
|
|
14436
|
+
recorded: ownershipRecords.records,
|
|
14437
|
+
unanswered,
|
|
14438
|
+
unreadable: ownershipRecords.unreadable,
|
|
14439
|
+
...ownershipError ? { error: ownershipError } : {},
|
|
14440
|
+
howToAnswer: "Record these with record_asset_ownership so the next run needs no interview."
|
|
14441
|
+
},
|
|
14442
|
+
keyRotationNote: "Key VALUES are not retrievable from GoHighLevel by any tool. The handoff document lists where each key lives so they can be rotated.",
|
|
14443
|
+
handoffDoc
|
|
14444
|
+
});
|
|
14445
|
+
} catch (error) {
|
|
14446
|
+
return errorResponse(error);
|
|
14447
|
+
} finally {
|
|
14448
|
+
client.setApiKey(originalKey);
|
|
14449
|
+
}
|
|
14450
|
+
}
|
|
14451
|
+
);
|
|
14452
|
+
server2.tool(
|
|
14453
|
+
"record_asset_ownership",
|
|
14454
|
+
"Record who owns an asset attached to a sub-account: the client, the agency, something borrowed for the engagement, or an outside vendor. Stored as custom values inside the client's own account, so the record outlives this tool and the client can read it without asking anyone. Answer these once at setup and build_offboarding_kit needs no interview later. Re-recording the same asset updates it in place instead of adding a duplicate. locationId is REQUIRED: writing ownership into the wrong client's account is worth preventing outright.",
|
|
14455
|
+
{
|
|
14456
|
+
locationId: import_zod46.z.string().describe("Sub-account to record ownership in. REQUIRED \u2014 never falls back to the active account."),
|
|
14457
|
+
assets: import_zod46.z.array(
|
|
14458
|
+
import_zod46.z.object({
|
|
14459
|
+
asset: import_zod46.z.string().describe("What it is, in the words you would say out loud. e.g. 'Website domain' or 'Phone number +16025551234'."),
|
|
14460
|
+
owner: import_zod46.z.enum(OWNER_KINDS).describe("client = stays with them. agency = leaves with you. borrowed = must be returned or replaced at exit. third-party = an outside vendor controls it."),
|
|
14461
|
+
note: import_zod46.z.string().optional().describe("Anything the next person needs, e.g. 'registered on the client's own GoDaddy account'.")
|
|
14462
|
+
})
|
|
14463
|
+
).min(1).describe("One entry per asset. Send several at once.")
|
|
14464
|
+
},
|
|
14465
|
+
async ({ locationId: locationId2, assets }) => {
|
|
14466
|
+
const originalKey = client.getApiKey();
|
|
14467
|
+
const registered = registry2?.getToken(locationId2);
|
|
14468
|
+
if (registered?.apiKey && registered.apiKey !== originalKey) client.setApiKey(registered.apiKey);
|
|
14469
|
+
try {
|
|
14470
|
+
const results = await writeOwnership(client, locationId2, assets);
|
|
14471
|
+
const halted = results.filter((r) => r.action === "halted");
|
|
14472
|
+
return jsonResponse({
|
|
14473
|
+
locationId: locationId2,
|
|
14474
|
+
recorded: results.filter((r) => r.action !== "halted"),
|
|
14475
|
+
...halted.length ? { halted, warning: "Some assets were NOT recorded. See halted for which, and why." } : {}
|
|
14476
|
+
});
|
|
14477
|
+
} catch (error) {
|
|
14478
|
+
return errorResponse(error);
|
|
14479
|
+
} finally {
|
|
14480
|
+
client.setApiKey(originalKey);
|
|
14481
|
+
}
|
|
14482
|
+
}
|
|
14483
|
+
);
|
|
14484
|
+
}
|
|
14485
|
+
function extractNames(payload) {
|
|
14486
|
+
const list = Array.isArray(payload) ? payload : payload && typeof payload === "object" ? Object.values(payload).find(Array.isArray) : void 0;
|
|
14487
|
+
if (!Array.isArray(list)) return [];
|
|
14488
|
+
return list.map(
|
|
14489
|
+
(item) => item && typeof item === "object" ? String(item.name ?? item.id ?? "") : String(item)
|
|
14490
|
+
).filter(Boolean);
|
|
14491
|
+
}
|
|
14492
|
+
async function readSection(read) {
|
|
14493
|
+
try {
|
|
14494
|
+
return await read();
|
|
14495
|
+
} catch (e) {
|
|
14496
|
+
return { unavailable: errorMessage(e) };
|
|
14497
|
+
}
|
|
14498
|
+
}
|
|
14499
|
+
async function readUsers(client, locationId2) {
|
|
14500
|
+
return readSection(async () => {
|
|
14501
|
+
const raw = await client.get("/users/", { params: { locationId: locationId2 } });
|
|
14502
|
+
const box = raw && typeof raw === "object" ? raw : {};
|
|
14503
|
+
const list = Array.isArray(box.users) ? box.users : [];
|
|
14504
|
+
return list.flatMap((entry) => {
|
|
14505
|
+
if (!entry || typeof entry !== "object") return [];
|
|
14506
|
+
const u = entry;
|
|
14507
|
+
const roles = u.roles && typeof u.roles === "object" ? String(u.roles.role ?? "") : "";
|
|
14508
|
+
return [{
|
|
14509
|
+
name: String(u.name ?? `${u.firstName ?? ""} ${u.lastName ?? ""}`).trim() || "(unnamed)",
|
|
14510
|
+
email: String(u.email ?? ""),
|
|
14511
|
+
roles
|
|
14512
|
+
}];
|
|
14513
|
+
});
|
|
14514
|
+
});
|
|
14515
|
+
}
|
|
14516
|
+
async function readPhoneNumbers(client, locationId2) {
|
|
14517
|
+
return readSection(async () => {
|
|
14518
|
+
const raw = await client.get("/phone-system/numbers/", { params: { locationId: locationId2 } });
|
|
14519
|
+
const box = raw && typeof raw === "object" ? raw : {};
|
|
14520
|
+
const list = Array.isArray(box.phoneNumbers) ? box.phoneNumbers : [];
|
|
14521
|
+
return list.flatMap((entry) => {
|
|
14522
|
+
if (!entry || typeof entry !== "object") return [];
|
|
14523
|
+
const n = entry;
|
|
14524
|
+
const number = String(n.value ?? n.number ?? "");
|
|
14525
|
+
return number ? [{ number, label: n.title ? String(n.title) : void 0 }] : [];
|
|
14526
|
+
});
|
|
14527
|
+
});
|
|
14528
|
+
}
|
|
14529
|
+
async function readSocialAccounts(client, locationId2) {
|
|
14530
|
+
return readSection(async () => {
|
|
14531
|
+
const raw = await client.get(`/social-media-posting/${locationId2}/accounts`);
|
|
14532
|
+
const box = raw && typeof raw === "object" ? raw : {};
|
|
14533
|
+
const results = box.results && typeof box.results === "object" ? box.results : {};
|
|
14534
|
+
const list = Array.isArray(results.accounts) ? results.accounts : [];
|
|
14535
|
+
return list.flatMap((entry) => {
|
|
14536
|
+
if (!entry || typeof entry !== "object") return [];
|
|
14537
|
+
const a = entry;
|
|
14538
|
+
return [{ platform: String(a.platform ?? "unknown"), name: String(a.name ?? a.id ?? "") }];
|
|
14539
|
+
});
|
|
14540
|
+
});
|
|
14541
|
+
}
|
|
14542
|
+
var OFF_PLATFORM_ASSETS = [
|
|
14543
|
+
"Website domain",
|
|
14544
|
+
"Logo and brand files",
|
|
14545
|
+
"Meta pixel or ad account",
|
|
14546
|
+
"Google Analytics",
|
|
14547
|
+
"Payment processor account",
|
|
14548
|
+
"Email sending domain"
|
|
14549
|
+
];
|
|
14550
|
+
function discoverAssets(input) {
|
|
14551
|
+
const assets = [];
|
|
14552
|
+
for (const p of sectionItems(input.phoneNumbers)) assets.push(`Phone number ${p.number}`);
|
|
14553
|
+
for (const s of sectionItems(input.socialAccounts)) assets.push(`${s.platform} account ${s.name}`.trim());
|
|
14554
|
+
const funnels = input.inventory.funnels;
|
|
14555
|
+
if (funnels && !funnels.unavailable) for (const name of funnels.names) assets.push(`Funnel "${name}"`);
|
|
14556
|
+
assets.push(...OFF_PLATFORM_ASSETS);
|
|
14557
|
+
return assets;
|
|
13896
14558
|
}
|
|
13897
14559
|
function buildLocationComparison(dataA, dataB, sections) {
|
|
13898
14560
|
const unavailable = [];
|
|
@@ -14589,7 +15251,7 @@ function buildOfferPayload(o) {
|
|
|
14589
15251
|
var import_zod52 = require("zod");
|
|
14590
15252
|
var fs7 = __toESM(require("fs"));
|
|
14591
15253
|
var path6 = __toESM(require("path"));
|
|
14592
|
-
function
|
|
15254
|
+
function delay4(ms) {
|
|
14593
15255
|
return new Promise((resolve8) => setTimeout(resolve8, ms));
|
|
14594
15256
|
}
|
|
14595
15257
|
var TemplateSchema = import_zod52.z.object({
|
|
@@ -14774,7 +15436,7 @@ function registerTemplateDeployerTools(server2, client) {
|
|
|
14774
15436
|
errors.push(`Location update: ${errorMessage(e)}`);
|
|
14775
15437
|
log.push(" \u2717 Location update failed (may need agency-level key)");
|
|
14776
15438
|
}
|
|
14777
|
-
await
|
|
15439
|
+
await delay4(200);
|
|
14778
15440
|
}
|
|
14779
15441
|
}
|
|
14780
15442
|
if (template.tags) {
|
|
@@ -14793,7 +15455,7 @@ Tags: ${template.tags.length} to create`);
|
|
|
14793
15455
|
log.push(` \u2717 Tag failed: ${tag}`);
|
|
14794
15456
|
}
|
|
14795
15457
|
}
|
|
14796
|
-
await
|
|
15458
|
+
await delay4(100);
|
|
14797
15459
|
}
|
|
14798
15460
|
} else {
|
|
14799
15461
|
for (const tag of template.tags) {
|
|
@@ -14823,7 +15485,7 @@ Custom Fields: ${template.customFields.length} to create`);
|
|
|
14823
15485
|
log.push(` \u2717 Field failed: ${field.name}`);
|
|
14824
15486
|
}
|
|
14825
15487
|
}
|
|
14826
|
-
await
|
|
15488
|
+
await delay4(100);
|
|
14827
15489
|
}
|
|
14828
15490
|
} else {
|
|
14829
15491
|
for (const field of template.customFields) {
|
|
@@ -14869,7 +15531,7 @@ Pipelines: ${template.pipelines.length} to create`);
|
|
|
14869
15531
|
} catch (e) {
|
|
14870
15532
|
errors.push(`Pipeline "${resolved2.name}": ${errorMessage(e)}`);
|
|
14871
15533
|
}
|
|
14872
|
-
await
|
|
15534
|
+
await delay4(300);
|
|
14873
15535
|
}
|
|
14874
15536
|
}
|
|
14875
15537
|
}
|
|
@@ -14907,7 +15569,7 @@ Workflows: ${workflows.length} to create`);
|
|
|
14907
15569
|
errors.push(`Workflow "${resolved2.name}": ${errorMessage(e)}`);
|
|
14908
15570
|
log.push(` \u2717 Workflow failed: ${resolved2.name}`);
|
|
14909
15571
|
}
|
|
14910
|
-
await
|
|
15572
|
+
await delay4(500);
|
|
14911
15573
|
}
|
|
14912
15574
|
}
|
|
14913
15575
|
}
|
|
@@ -14932,7 +15594,7 @@ Calendars: ${template.calendars.length} to create`);
|
|
|
14932
15594
|
errors.push(`Calendar "${resolved2.name}": ${errorMessage(e)}`);
|
|
14933
15595
|
log.push(` \u2717 Calendar failed: ${resolved2.name}`);
|
|
14934
15596
|
}
|
|
14935
|
-
await
|
|
15597
|
+
await delay4(200);
|
|
14936
15598
|
}
|
|
14937
15599
|
}
|
|
14938
15600
|
}
|
|
@@ -19411,7 +20073,7 @@ function registerAllTools(server2, client, registry2, mcpVersion, env = process.
|
|
|
19411
20073
|
() => buildGatingReport(config3, KNOWN_MODULES, attemptedTools, registeredTools, outOfBand)
|
|
19412
20074
|
);
|
|
19413
20075
|
registerSnapshotTools(wrap(SNAPSHOTS_MODULE), client, registry2);
|
|
19414
|
-
registerAccountExportTools(wrap(ACCOUNT_EXPORT_MODULE), client, registry2);
|
|
20076
|
+
registerAccountExportTools(wrap(ACCOUNT_EXPORT_MODULE), client, registry2, builderClient);
|
|
19415
20077
|
registerLocationSwitcherTools(
|
|
19416
20078
|
wrap(LOCATION_SWITCHER_MODULE),
|
|
19417
20079
|
client,
|