@leadbay/mcp 0.3.0 → 0.4.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,24 @@
|
|
|
1
1
|
# Changelog — @leadbay/mcp
|
|
2
2
|
|
|
3
|
+
## 0.4.0 — 2026-05-04
|
|
4
|
+
|
|
5
|
+
### `leadbay_import_leads` 0.2.0 — custom field mapping
|
|
6
|
+
|
|
7
|
+
The MCP tool now drives the same CRM-import wizard the web UI exposes — pass arbitrary CSV-shaped records and tell Leadbay which column maps to which `StandardCrmFieldType`.
|
|
8
|
+
|
|
9
|
+
**Two modes** (pass exactly one of `domains` / `records`):
|
|
10
|
+
|
|
11
|
+
- **Mode A (existing, unchanged):** `domains: [{domain, name?}]` — synthesizes a 2-column CSV (LEAD_NAME, LEAD_WEBSITE) and uses the default mapping. Output shape is identical to 0.1.x: `{ leads: [{domain, leadId, name}], not_imported: [{domain, reason}], ... }`.
|
|
12
|
+
- **Mode B (new):** `records: [{Col1, Col2, ...}]` plus `mappings: { fields: { Col1: "LEAD_NAME", Col2: "LEAD_WEBSITE", Col3: "LEAD_SECTOR", ... } }`. The tool synthesizes a CSV from the union of record keys (sorted, deterministic) and POSTs the caller-supplied mapping to `/imports/{id}/update_mappings`. Output shape: `{ leads: [{rowId, domain?, leadId, name}], not_imported: [{rowId, domain?, reason}], ... }`. `rowId` round-trips your input row order; `domain` populated only when `LEAD_WEBSITE` was mapped and the value parsed.
|
|
13
|
+
|
|
14
|
+
Mappings.fields must include `LEAD_NAME` or `LEAD_WEBSITE` — the wizard's resolver needs at least one of those to find a lead. Other CRM fields (`LEAD_SECTOR`, `LEAD_LOCATION`, `LEAD_SIZE`, `EMAIL`, `CRM_ID`, `LEADBAY_ID`, `DEAL_CRM_ID`, `CONTACT_TITLE`, `LEAD_STATUS`, `LEAD_STATUS_DATE`) are passed through verbatim.
|
|
15
|
+
|
|
16
|
+
**Validation (records mode):** new typed error codes — `IMPORT_INPUT_CONFLICT` (both modes supplied), `IMPORT_MAPPING_REQUIRED` (no mappings.fields), `IMPORT_MAPPING_NO_RESOLVER` (no LEAD_NAME or LEAD_WEBSITE in mapping), `IMPORT_MAPPING_KEY_UNKNOWN` (mapping key absent from records), `IMPORT_RESERVED_COLUMN` (record or mapping key matches `MCP_ROW_ID` case-insensitively), `IMPORT_INVALID_COLUMN_NAME` (column name >128 chars or contains control chars), `IMPORT_INVALID_CELL_TYPE` (cell value is array/object — coerce to string before passing). null/undefined cells coerce to "", numbers/booleans coerce via `String(v)`.
|
|
17
|
+
|
|
18
|
+
**Security:** user-supplied column names now flow through the same `escapeCsvCell` (RFC 4180 quoting + formula-injection prefix) that data values use. Header injection vectors (`=`, `+`, `-`, `@`, `,`, `"`, newline) are neutered.
|
|
19
|
+
|
|
20
|
+
**Backward compat:** Mode A output shape unchanged — the new `rowId` field is records-mode only. Existing `domains: [...]` callers see no diff.
|
|
21
|
+
|
|
3
22
|
## 0.3.0 — 2026-04-29
|
|
4
23
|
|
|
5
24
|
Behavior-changing release: closes [product#3504](https://github.com/leadbay/product/issues/3504) end-to-end. Default-installed MCP server now matches its own system prompt out of the box, the `login` command never lands a bearer token in scrollback by default, and `claude mcp add` registers Leadbay at user scope so it's visible from any project.
|
package/dist/bin.js
CHANGED
|
@@ -8,7 +8,7 @@ import {
|
|
|
8
8
|
granularReadTools,
|
|
9
9
|
granularWriteTools,
|
|
10
10
|
resolveRegion
|
|
11
|
-
} from "./chunk-
|
|
11
|
+
} from "./chunk-O2UOXRZO.js";
|
|
12
12
|
|
|
13
13
|
// src/bin.ts
|
|
14
14
|
import { realpathSync } from "fs";
|
|
@@ -158,7 +158,7 @@ function buildServer(client, opts = {}) {
|
|
|
158
158
|
|
|
159
159
|
// src/bin.ts
|
|
160
160
|
import { createRequire } from "module";
|
|
161
|
-
var VERSION = "0.
|
|
161
|
+
var VERSION = "0.4.0";
|
|
162
162
|
var HELP = `
|
|
163
163
|
leadbay-mcp ${VERSION} \u2014 Leadbay Model Context Protocol server
|
|
164
164
|
|
|
@@ -447,7 +447,7 @@ async function runLogin(args) {
|
|
|
447
447
|
let result;
|
|
448
448
|
try {
|
|
449
449
|
if (pinnedRegion && !allowFallback) {
|
|
450
|
-
const { REGIONS } = await import("./dist-
|
|
450
|
+
const { REGIONS } = await import("./dist-RONMQBYU.js");
|
|
451
451
|
const baseUrl = REGIONS[pinnedRegion];
|
|
452
452
|
const c = createClient({ region: pinnedRegion });
|
|
453
453
|
const token = await loginAt(baseUrl, email, password);
|
|
@@ -939,7 +939,7 @@ leadbay-mcp install \u2014 detected MCP clients on this machine:
|
|
|
939
939
|
let region;
|
|
940
940
|
try {
|
|
941
941
|
if (pinnedRegion && !allowFallback) {
|
|
942
|
-
const { REGIONS } = await import("./dist-
|
|
942
|
+
const { REGIONS } = await import("./dist-RONMQBYU.js");
|
|
943
943
|
const baseUrl = REGIONS[pinnedRegion];
|
|
944
944
|
token = await loginAt(baseUrl, email, password);
|
|
945
945
|
region = pinnedRegion;
|
|
@@ -2286,6 +2286,8 @@ var POLL_INTERVAL_MS = 2e3;
|
|
|
2286
2286
|
var DEFAULT_PER_PHASE_BUDGET_MS = 6e4;
|
|
2287
2287
|
var DEFAULT_TOTAL_BUDGET_MS2 = 3e5;
|
|
2288
2288
|
var STABILIZATION_POLLS = 2;
|
|
2289
|
+
var MAX_COLUMN_NAME_LEN = 128;
|
|
2290
|
+
var RESERVED_COLUMN_RE = /^mcp_row_id$/i;
|
|
2289
2291
|
var PUBLIC_MAILBOX_DOMAINS = /* @__PURE__ */ new Set([
|
|
2290
2292
|
"gmail.com",
|
|
2291
2293
|
"googlemail.com",
|
|
@@ -2357,12 +2359,10 @@ function escapeCsvCell(raw) {
|
|
|
2357
2359
|
}
|
|
2358
2360
|
return s;
|
|
2359
2361
|
}
|
|
2360
|
-
function synthesizeCsv(rows) {
|
|
2361
|
-
const
|
|
2362
|
-
|
|
2363
|
-
|
|
2364
|
-
}
|
|
2365
|
-
return lines.join("\n") + "\n";
|
|
2362
|
+
function synthesizeCsv(header, rows) {
|
|
2363
|
+
const headerLine = header.map(escapeCsvCell).join(",");
|
|
2364
|
+
const dataLines = rows.map((r) => header.map((col) => escapeCsvCell(r[col] ?? "")).join(","));
|
|
2365
|
+
return [headerLine, ...dataLines].join("\n") + "\n";
|
|
2366
2366
|
}
|
|
2367
2367
|
function chunkAt100(items) {
|
|
2368
2368
|
if (items.length === 0)
|
|
@@ -2430,27 +2430,144 @@ function readCell(record, key) {
|
|
|
2430
2430
|
}
|
|
2431
2431
|
return null;
|
|
2432
2432
|
}
|
|
2433
|
-
function
|
|
2433
|
+
function validateColumnName(client, name, path) {
|
|
2434
|
+
if (typeof name !== "string" || name.length === 0) {
|
|
2435
|
+
throw client.makeError("IMPORT_INVALID_COLUMN_NAME", `Column name at ${path} must be a non-empty string`, `Use a plain string column name (1-${MAX_COLUMN_NAME_LEN} chars).`, "POST /imports");
|
|
2436
|
+
}
|
|
2437
|
+
if (name.length > MAX_COLUMN_NAME_LEN) {
|
|
2438
|
+
throw client.makeError("IMPORT_INVALID_COLUMN_NAME", `Column name at ${path} exceeds ${MAX_COLUMN_NAME_LEN} chars`, `Shorten the column name to ${MAX_COLUMN_NAME_LEN} chars or fewer.`, "POST /imports");
|
|
2439
|
+
}
|
|
2440
|
+
if (/[\x00-\x1F\x7F]/.test(name)) {
|
|
2441
|
+
throw client.makeError("IMPORT_INVALID_COLUMN_NAME", `Column name at ${path} contains control characters`, `Strip control characters (e.g. \\n, \\t, \\x00) from column names.`, "POST /imports");
|
|
2442
|
+
}
|
|
2443
|
+
}
|
|
2444
|
+
function coerceCell(client, v, path) {
|
|
2445
|
+
if (v == null)
|
|
2446
|
+
return "";
|
|
2447
|
+
if (typeof v === "string")
|
|
2448
|
+
return v;
|
|
2449
|
+
if (typeof v === "number" || typeof v === "boolean")
|
|
2450
|
+
return String(v);
|
|
2451
|
+
throw client.makeError("IMPORT_INVALID_CELL_TYPE", `Cell at ${path} is ${Array.isArray(v) ? "an array" : typeof v}, expected string|number|boolean|null`, `Convert the value to a string before passing.`, "POST /imports");
|
|
2452
|
+
}
|
|
2453
|
+
function prepareDomainsMode(client, inputs) {
|
|
2434
2454
|
const validInputs = [];
|
|
2435
|
-
const
|
|
2455
|
+
const malformedDomains = [];
|
|
2436
2456
|
const byDomain = /* @__PURE__ */ new Map();
|
|
2437
2457
|
const byRowId = /* @__PURE__ */ new Map();
|
|
2438
|
-
|
|
2439
|
-
|
|
2440
|
-
const norm = normalizeDomain(inp.domain ?? "");
|
|
2458
|
+
for (const inp of inputs) {
|
|
2459
|
+
const norm = normalizeDomain(inp?.domain ?? "");
|
|
2441
2460
|
if (!norm) {
|
|
2442
|
-
|
|
2443
|
-
|
|
2461
|
+
malformedDomains.push(inp?.domain ?? "");
|
|
2462
|
+
continue;
|
|
2444
2463
|
}
|
|
2445
2464
|
if (byDomain.has(norm))
|
|
2446
|
-
|
|
2465
|
+
continue;
|
|
2447
2466
|
const rowId = randomUUID();
|
|
2448
2467
|
const idx = validInputs.length;
|
|
2449
|
-
|
|
2468
|
+
const name = inp.name?.trim() || norm;
|
|
2469
|
+
validInputs.push({
|
|
2470
|
+
index: idx,
|
|
2471
|
+
rowId,
|
|
2472
|
+
row: { MCP_ROW_ID: rowId, LEAD_NAME: name, LEAD_WEBSITE: norm },
|
|
2473
|
+
domain: norm,
|
|
2474
|
+
outputDomain: norm
|
|
2475
|
+
});
|
|
2450
2476
|
byDomain.set(norm, idx);
|
|
2451
2477
|
byRowId.set(rowId, idx);
|
|
2478
|
+
}
|
|
2479
|
+
return {
|
|
2480
|
+
mode: "domains",
|
|
2481
|
+
validInputs,
|
|
2482
|
+
malformedDomains,
|
|
2483
|
+
byDomain,
|
|
2484
|
+
byRowId,
|
|
2485
|
+
header: ["MCP_ROW_ID", "LEAD_NAME", "LEAD_WEBSITE"],
|
|
2486
|
+
mappings: {
|
|
2487
|
+
fields: { LEAD_NAME: "LEAD_NAME", LEAD_WEBSITE: "LEAD_WEBSITE" },
|
|
2488
|
+
statuses: {},
|
|
2489
|
+
default_status: null
|
|
2490
|
+
}
|
|
2491
|
+
};
|
|
2492
|
+
}
|
|
2493
|
+
function prepareRecordsMode(client, records, mappings) {
|
|
2494
|
+
if (!mappings || !mappings.fields || typeof mappings.fields !== "object") {
|
|
2495
|
+
throw client.makeError("IMPORT_MAPPING_REQUIRED", "records[] requires a mappings.fields object", "Pass `mappings: { fields: { CsvColumn: 'LEAD_NAME', ... } }`.", "POST /imports");
|
|
2496
|
+
}
|
|
2497
|
+
const fieldEntries = Object.entries(mappings.fields);
|
|
2498
|
+
if (fieldEntries.length === 0) {
|
|
2499
|
+
throw client.makeError("IMPORT_MAPPING_REQUIRED", "mappings.fields must contain at least one column \u2192 CRM field entry", "Map at least one CSV column to LEAD_NAME or LEAD_WEBSITE.", "POST /imports");
|
|
2500
|
+
}
|
|
2501
|
+
const targets = new Set(fieldEntries.map(([, v]) => v));
|
|
2502
|
+
if (!targets.has("LEAD_NAME") && !targets.has("LEAD_WEBSITE")) {
|
|
2503
|
+
throw client.makeError("IMPORT_MAPPING_NO_RESOLVER", "mappings.fields must include LEAD_NAME or LEAD_WEBSITE", "The wizard needs at least one of those fields to match a lead. Map a CSV column to one of them.", "POST /imports");
|
|
2504
|
+
}
|
|
2505
|
+
for (const [colName] of fieldEntries) {
|
|
2506
|
+
validateColumnName(client, colName, `mappings.fields[${JSON.stringify(colName)}]`);
|
|
2507
|
+
if (RESERVED_COLUMN_RE.test(colName)) {
|
|
2508
|
+
throw client.makeError("IMPORT_RESERVED_COLUMN", `mappings.fields key '${colName}' collides with reserved synthetic column MCP_ROW_ID`, `Rename the column. MCP_ROW_ID (any case) is reserved for tool-internal reconciliation.`, "POST /imports");
|
|
2509
|
+
}
|
|
2510
|
+
}
|
|
2511
|
+
const headerSet = /* @__PURE__ */ new Set();
|
|
2512
|
+
const coercedRecords = [];
|
|
2513
|
+
records.forEach((rec, i) => {
|
|
2514
|
+
if (rec == null || typeof rec !== "object" || Array.isArray(rec)) {
|
|
2515
|
+
throw client.makeError("IMPORT_INVALID_CELL_TYPE", `records[${i}] must be a plain object`, `Pass each record as { ColumnName: value, ... }.`, "POST /imports");
|
|
2516
|
+
}
|
|
2517
|
+
const out = {};
|
|
2518
|
+
for (const [k, v] of Object.entries(rec)) {
|
|
2519
|
+
validateColumnName(client, k, `records[${i}] key`);
|
|
2520
|
+
if (RESERVED_COLUMN_RE.test(k)) {
|
|
2521
|
+
throw client.makeError("IMPORT_RESERVED_COLUMN", `records[${i}] key '${k}' collides with reserved synthetic column MCP_ROW_ID`, `Rename the column in your records (any case variant of MCP_ROW_ID is reserved).`, "POST /imports");
|
|
2522
|
+
}
|
|
2523
|
+
out[k] = coerceCell(client, v, `records[${i}].${k}`);
|
|
2524
|
+
headerSet.add(k);
|
|
2525
|
+
}
|
|
2526
|
+
coercedRecords.push(out);
|
|
2452
2527
|
});
|
|
2453
|
-
|
|
2528
|
+
for (const [colName] of fieldEntries) {
|
|
2529
|
+
if (!headerSet.has(colName)) {
|
|
2530
|
+
throw client.makeError("IMPORT_MAPPING_KEY_UNKNOWN", `mappings.fields key '${colName}' is not present in any record`, `Add a value for '${colName}' to at least one record, or remove it from mappings.`, "POST /imports");
|
|
2531
|
+
}
|
|
2532
|
+
}
|
|
2533
|
+
const userKeys = [...headerSet].sort();
|
|
2534
|
+
const header = ["MCP_ROW_ID", ...userKeys];
|
|
2535
|
+
const websiteCol = fieldEntries.find(([, v]) => v === "LEAD_WEBSITE")?.[0];
|
|
2536
|
+
const validInputs = [];
|
|
2537
|
+
const byDomain = /* @__PURE__ */ new Map();
|
|
2538
|
+
const byRowId = /* @__PURE__ */ new Map();
|
|
2539
|
+
coercedRecords.forEach((row) => {
|
|
2540
|
+
const rowId = randomUUID();
|
|
2541
|
+
const idx = validInputs.length;
|
|
2542
|
+
let normDomain = null;
|
|
2543
|
+
if (websiteCol) {
|
|
2544
|
+
normDomain = normalizeDomain(row[websiteCol] ?? "");
|
|
2545
|
+
}
|
|
2546
|
+
const fullRow = { MCP_ROW_ID: rowId, ...row };
|
|
2547
|
+
validInputs.push({
|
|
2548
|
+
index: idx,
|
|
2549
|
+
rowId,
|
|
2550
|
+
row: fullRow,
|
|
2551
|
+
domain: normDomain,
|
|
2552
|
+
outputDomain: normDomain ?? void 0
|
|
2553
|
+
});
|
|
2554
|
+
byRowId.set(rowId, idx);
|
|
2555
|
+
if (normDomain && !byDomain.has(normDomain))
|
|
2556
|
+
byDomain.set(normDomain, idx);
|
|
2557
|
+
});
|
|
2558
|
+
return {
|
|
2559
|
+
mode: "records",
|
|
2560
|
+
validInputs,
|
|
2561
|
+
malformedDomains: [],
|
|
2562
|
+
byDomain,
|
|
2563
|
+
byRowId,
|
|
2564
|
+
header,
|
|
2565
|
+
mappings: {
|
|
2566
|
+
fields: { ...mappings.fields },
|
|
2567
|
+
statuses: mappings.statuses ?? {},
|
|
2568
|
+
default_status: mappings.default_status ?? null
|
|
2569
|
+
}
|
|
2570
|
+
};
|
|
2454
2571
|
}
|
|
2455
2572
|
async function pollUntil(fn, done, budgetMs, signal, ctx, label) {
|
|
2456
2573
|
const deadline = Date.now() + budgetMs;
|
|
@@ -2538,13 +2655,13 @@ async function pollRecordsToTerminal(client, importId, budgetMs, expectedRowCoun
|
|
|
2538
2655
|
}
|
|
2539
2656
|
if (Date.now() >= deadline) {
|
|
2540
2657
|
ctx?.logger?.warn?.(`import-leads: records did not stabilize (transient=${transient}, total=${total}); returning best-effort`);
|
|
2541
|
-
throw client.makeError("IMPORT_NOT_TERMINAL", `Backend hasn't fully settled records within ${budgetMs}ms`, `Retry leadbay_import_leads with the same
|
|
2658
|
+
throw client.makeError("IMPORT_NOT_TERMINAL", `Backend hasn't fully settled records within ${budgetMs}ms`, `Retry leadbay_import_leads with the same input in 30s, or split the batch. importId=${importId}.`, `GET /imports/${importId}/records`);
|
|
2542
2659
|
}
|
|
2543
2660
|
await sleepWithAbort(POLL_INTERVAL_MS, signal);
|
|
2544
2661
|
}
|
|
2545
2662
|
}
|
|
2546
|
-
async function runOneChunk(client, chunk, chunkIdx, totalChunks, dryRun, perPhaseBudgetMs, totalDeadline, ctx, signal, onImportId) {
|
|
2547
|
-
const csv = synthesizeCsv(chunk.map((c) =>
|
|
2663
|
+
async function runOneChunk(client, chunk, chunkIdx, totalChunks, header, mappings, dryRun, perPhaseBudgetMs, totalDeadline, ctx, signal, onImportId) {
|
|
2664
|
+
const csv = synthesizeCsv(header, chunk.map((c) => c.row));
|
|
2548
2665
|
const ts = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
|
|
2549
2666
|
const fileName = `mcp-import-${ts}-${chunkIdx}.csv`;
|
|
2550
2667
|
ctx?.logger?.info?.(`import-leads: uploading chunk ${chunkIdx + 1}/${totalChunks} (${chunk.length} rows, ${csv.length}B)`);
|
|
@@ -2557,11 +2674,6 @@ async function runOneChunk(client, chunk, chunkIdx, totalChunks, dryRun, perPhas
|
|
|
2557
2674
|
if (dryRun) {
|
|
2558
2675
|
return { importId, records: [] };
|
|
2559
2676
|
}
|
|
2560
|
-
const mappings = {
|
|
2561
|
-
fields: { LEAD_NAME: "LEAD_NAME", LEAD_WEBSITE: "LEAD_WEBSITE" },
|
|
2562
|
-
statuses: {},
|
|
2563
|
-
default_status: null
|
|
2564
|
-
};
|
|
2565
2677
|
await client.requestVoid("POST", `/imports/${importId}/update_mappings`, mappings);
|
|
2566
2678
|
ctx?.logger?.info?.(`import-leads: mappings committed for importId=${importId}`);
|
|
2567
2679
|
const phaseBudget2 = Math.min(perPhaseBudgetMs, Math.max(1, totalDeadline - Date.now()));
|
|
@@ -2572,7 +2684,7 @@ async function runOneChunk(client, chunk, chunkIdx, totalChunks, dryRun, perPhas
|
|
|
2572
2684
|
ctx?.logger?.info?.(`import-leads: ${records.length} records terminal for importId=${importId}`);
|
|
2573
2685
|
return { importId, records };
|
|
2574
2686
|
}
|
|
2575
|
-
function reconcileOneChunk(
|
|
2687
|
+
function reconcileOneChunk(prep, chunk, matched, notImported) {
|
|
2576
2688
|
const seenInputIndex = /* @__PURE__ */ new Set();
|
|
2577
2689
|
const sortedRecords = [...chunk.records].sort((a, b) => {
|
|
2578
2690
|
const aHasLead = a.lead?.id ? 0 : 1;
|
|
@@ -2582,61 +2694,61 @@ function reconcileOneChunk(chunk, byRowIdGlobal, byDomainGlobal, validInputsGlob
|
|
|
2582
2694
|
for (const rec of sortedRecords) {
|
|
2583
2695
|
let inputIdx;
|
|
2584
2696
|
const rowIdCell = readCell(rec, "MCP_ROW_ID");
|
|
2585
|
-
if (rowIdCell &&
|
|
2586
|
-
inputIdx =
|
|
2697
|
+
if (rowIdCell && prep.byRowId.has(rowIdCell)) {
|
|
2698
|
+
inputIdx = prep.byRowId.get(rowIdCell);
|
|
2587
2699
|
}
|
|
2588
2700
|
if (inputIdx === void 0) {
|
|
2589
2701
|
const websiteCell = readCell(rec, "LEAD_WEBSITE");
|
|
2590
2702
|
if (websiteCell) {
|
|
2591
2703
|
const norm = normalizeDomain(websiteCell);
|
|
2592
|
-
if (norm &&
|
|
2593
|
-
inputIdx =
|
|
2704
|
+
if (norm && prep.byDomain.has(norm)) {
|
|
2705
|
+
inputIdx = prep.byDomain.get(norm);
|
|
2594
2706
|
}
|
|
2595
2707
|
}
|
|
2596
2708
|
}
|
|
2597
2709
|
if (inputIdx === void 0 && rec.lead?.website) {
|
|
2598
2710
|
const norm = normalizeDomain(rec.lead.website);
|
|
2599
|
-
if (norm &&
|
|
2600
|
-
inputIdx =
|
|
2711
|
+
if (norm && prep.byDomain.has(norm)) {
|
|
2712
|
+
inputIdx = prep.byDomain.get(norm);
|
|
2601
2713
|
}
|
|
2602
2714
|
}
|
|
2603
2715
|
if (inputIdx === void 0)
|
|
2604
2716
|
continue;
|
|
2605
2717
|
if (seenInputIndex.has(inputIdx)) {
|
|
2606
2718
|
if (!matched.has(inputIdx) && !notImported.has(inputIdx)) {
|
|
2607
|
-
const inp2 =
|
|
2608
|
-
notImported.set(inputIdx, { domain: inp2.
|
|
2719
|
+
const inp2 = prep.validInputs[inputIdx];
|
|
2720
|
+
notImported.set(inputIdx, { domain: inp2.outputDomain, reason: "ambiguous" });
|
|
2609
2721
|
}
|
|
2610
2722
|
continue;
|
|
2611
2723
|
}
|
|
2612
2724
|
seenInputIndex.add(inputIdx);
|
|
2613
|
-
const inp =
|
|
2725
|
+
const inp = prep.validInputs[inputIdx];
|
|
2614
2726
|
const matchType = (rec.match_type ?? rec.matchType ?? "").toString();
|
|
2615
2727
|
if (rec.lead?.id) {
|
|
2616
2728
|
matched.set(inputIdx, {
|
|
2617
|
-
domain: inp.
|
|
2729
|
+
domain: inp.outputDomain,
|
|
2618
2730
|
leadId: rec.lead.id,
|
|
2619
2731
|
name: rec.lead.name ?? null
|
|
2620
2732
|
});
|
|
2621
2733
|
} else if (matchType === "NO_MATCH") {
|
|
2622
|
-
const reason = PUBLIC_MAILBOX_DOMAINS.has(inp.domain) ? "no_match" : "uncrawled";
|
|
2623
|
-
notImported.set(inputIdx, { domain: inp.
|
|
2734
|
+
const reason = inp.domain && PUBLIC_MAILBOX_DOMAINS.has(inp.domain) ? "no_match" : "uncrawled";
|
|
2735
|
+
notImported.set(inputIdx, { domain: inp.outputDomain, reason });
|
|
2624
2736
|
} else {
|
|
2625
|
-
notImported.set(inputIdx, { domain: inp.
|
|
2737
|
+
notImported.set(inputIdx, { domain: inp.outputDomain, reason: "internal_error" });
|
|
2626
2738
|
}
|
|
2627
2739
|
}
|
|
2628
2740
|
}
|
|
2629
2741
|
var importLeads = {
|
|
2630
2742
|
name: "leadbay_import_leads",
|
|
2631
|
-
description: "Import
|
|
2743
|
+
description: "Import leads into Leadbay's CRM via the file-import wizard. Returns stable Leadbay leadIds for downstream chaining into leadbay_bulk_qualify_leads / leadbay_research_lead.\n\nTWO MODES:\n A) Domain-list shortcut \u2014 pass `domains: [{domain, name?}]`. The tool builds a 2-column CSV (LEAD_NAME, LEAD_WEBSITE) and imports with the default mapping. Output: { leads: [{domain, leadId, name}], not_imported: [{domain, reason}], importIds, _meta }.\n B) Custom records + mapping \u2014 pass `records: [{Col1, Col2, ...}]` plus `mappings.fields: {Col1: 'LEAD_NAME', Col2: 'LEAD_WEBSITE', ...}`. The tool synthesizes a CSV from the union of record keys (deterministic order) and POSTs the caller-supplied mapping to the wizard. mappings.fields must include LEAD_NAME or LEAD_WEBSITE (the resolver needs at least one). Output: { leads: [{rowId, domain?, leadId, name}], not_imported: [{rowId, domain?, reason}], importIds, _meta }. `rowId` round-trips your input order.\n\nPass exactly one of `domains` / `records`. Reserved column MCP_ROW_ID (any case) cannot appear in records or mappings \u2014 the tool injects it for stable reconciliation.\n\n\u26A0\uFE0F MUTATES USER STATE. Each call:\n - creates a row in the user's CRM-imports list (visible in the web UI)\n - touches onboarding state (startFileless, onboarding step \u2192 PROCESSING)\nSuitable for occasional automation. NOT suitable for high-cadence (>5 calls/day) \u2014 wait for the backend programmatic endpoint (issue: leadbay/backend prolonged-import-with-crawl).\n\nWhen to use: you have a list of company domains from another system (CRM, analytics, email correspondents) and need stable Leadbay leadIds; or you have CRM-shaped rows with custom columns (sector, location, status, etc.) and want to drive the wizard with explicit field mappings.\nWhen NOT to use: for prospect discovery (use leadbay_pull_leads); for one specific company's profile (use leadbay_research_company); when you can't tolerate the side effects above.\n\nRequires: LEADBAY_MCP_WRITE=1 (MCP) or exposeWrite=true (OpenClaw); admin role on the Leadbay account; active billing.",
|
|
2632
2744
|
write: true,
|
|
2633
|
-
version: "0.
|
|
2745
|
+
version: "0.2.0",
|
|
2634
2746
|
inputSchema: {
|
|
2635
2747
|
type: "object",
|
|
2636
2748
|
properties: {
|
|
2637
2749
|
domains: {
|
|
2638
2750
|
type: "array",
|
|
2639
|
-
description: "
|
|
2751
|
+
description: "Mode A: list of company domains to map to Leadbay leadIds. Mutually exclusive with `records`.",
|
|
2640
2752
|
items: {
|
|
2641
2753
|
type: "object",
|
|
2642
2754
|
properties: {
|
|
@@ -2652,9 +2764,33 @@ var importLeads = {
|
|
|
2652
2764
|
required: ["domain"]
|
|
2653
2765
|
}
|
|
2654
2766
|
},
|
|
2767
|
+
records: {
|
|
2768
|
+
type: "array",
|
|
2769
|
+
description: "Mode B: arbitrary CSV-shaped rows. Each record is an object whose keys are column names and values are scalar (string/number/boolean/null). Mutually exclusive with `domains`. Must be accompanied by `mappings.fields`. The tool synthesizes a CSV from the union of all record keys.",
|
|
2770
|
+
items: { type: "object" }
|
|
2771
|
+
},
|
|
2772
|
+
mappings: {
|
|
2773
|
+
type: "object",
|
|
2774
|
+
description: "Mode B: how each CSV column maps to Leadbay's CRM field schema. Required when `records` is supplied; ignored otherwise.",
|
|
2775
|
+
properties: {
|
|
2776
|
+
fields: {
|
|
2777
|
+
type: "object",
|
|
2778
|
+
description: "Object whose keys are CSV column names (matching keys in `records`) and whose values are Leadbay CRM field types (LEAD_NAME, LEAD_WEBSITE, LEAD_STATUS, LEAD_LOCATION, LEAD_SECTOR, LEAD_SIZE, CRM_ID, LEADBAY_ID, EMAIL, DEAL_CRM_ID, CONTACT_TITLE, LEAD_STATUS_DATE). At least one entry must target LEAD_NAME or LEAD_WEBSITE \u2014 the wizard needs that to find leads."
|
|
2779
|
+
},
|
|
2780
|
+
statuses: {
|
|
2781
|
+
type: "object",
|
|
2782
|
+
description: "Optional status string mapping (rarely needed). Defaults to {}."
|
|
2783
|
+
},
|
|
2784
|
+
default_status: {
|
|
2785
|
+
type: ["string", "null"],
|
|
2786
|
+
description: "Optional default status. Defaults to null."
|
|
2787
|
+
}
|
|
2788
|
+
},
|
|
2789
|
+
required: ["fields"]
|
|
2790
|
+
},
|
|
2655
2791
|
dry_run: {
|
|
2656
2792
|
type: "boolean",
|
|
2657
|
-
description: "If true, run preprocess only \u2014 do NOT commit lead-CRM linking. Note: an import row still appears in the user's CRM-imports list as 'incomplete'. Use to verify
|
|
2793
|
+
description: "If true, run preprocess only \u2014 do NOT commit lead-CRM linking. Note: an import row still appears in the user's CRM-imports list as 'incomplete'. Use to verify input format / wizard reachability without polluting the CRM."
|
|
2658
2794
|
},
|
|
2659
2795
|
per_phase_budget_ms: {
|
|
2660
2796
|
type: "number",
|
|
@@ -2664,8 +2800,9 @@ var importLeads = {
|
|
|
2664
2800
|
type: "number",
|
|
2665
2801
|
description: `Overall cap across all phases (default ${DEFAULT_TOTAL_BUDGET_MS2}ms).`
|
|
2666
2802
|
}
|
|
2667
|
-
}
|
|
2668
|
-
|
|
2803
|
+
}
|
|
2804
|
+
// Neither field is "required" at the schema level; xor + presence is
|
|
2805
|
+
// enforced in execute() so we can produce specific error codes.
|
|
2669
2806
|
},
|
|
2670
2807
|
execute: async (client, params, ctx) => {
|
|
2671
2808
|
const signal = ctx?.signal;
|
|
@@ -2673,16 +2810,21 @@ var importLeads = {
|
|
|
2673
2810
|
const perPhaseBudget = params.per_phase_budget_ms ?? DEFAULT_PER_PHASE_BUDGET_MS;
|
|
2674
2811
|
const totalBudget = params.total_budget_ms ?? DEFAULT_TOTAL_BUDGET_MS2;
|
|
2675
2812
|
const totalDeadline = Date.now() + totalBudget;
|
|
2676
|
-
|
|
2677
|
-
|
|
2813
|
+
const hasDomains = Array.isArray(params.domains) && params.domains.length > 0;
|
|
2814
|
+
const hasRecords = Array.isArray(params.records) && params.records.length > 0;
|
|
2815
|
+
if (hasDomains && hasRecords) {
|
|
2816
|
+
throw client.makeError("IMPORT_INPUT_CONFLICT", "Pass exactly one of `domains` or `records`, not both", "Use `domains` for the simple shortcut, or `records`+`mappings` for arbitrary CSV input.", "POST /imports");
|
|
2817
|
+
}
|
|
2818
|
+
if (!hasDomains && !hasRecords) {
|
|
2819
|
+
throw client.makeError("IMPORT_EMPTY_INPUT", "domains[] or records[] must contain at least one entry", "Pass at least one entry. See the tool description for the two input modes.", "POST /imports");
|
|
2678
2820
|
}
|
|
2679
2821
|
const me = await client.resolveMe();
|
|
2680
2822
|
if (!me.admin) {
|
|
2681
2823
|
throw client.makeError("IMPORT_ADMIN_REQUIRED", "This tool requires admin role on the Leadbay account", "Ask the account owner to grant import permission, or use a token from an admin user.", "POST /imports");
|
|
2682
2824
|
}
|
|
2683
|
-
const
|
|
2684
|
-
if (
|
|
2685
|
-
const not_imported2 =
|
|
2825
|
+
const prep = hasDomains ? prepareDomainsMode(client, params.domains) : prepareRecordsMode(client, params.records, params.mappings);
|
|
2826
|
+
if (prep.validInputs.length === 0) {
|
|
2827
|
+
const not_imported2 = prep.malformedDomains.map((d) => ({
|
|
2686
2828
|
domain: d,
|
|
2687
2829
|
reason: "malformed"
|
|
2688
2830
|
}));
|
|
@@ -2700,8 +2842,8 @@ var importLeads = {
|
|
|
2700
2842
|
}
|
|
2701
2843
|
};
|
|
2702
2844
|
}
|
|
2703
|
-
const chunks = chunkAt100(
|
|
2704
|
-
ctx?.logger?.info?.(`import-leads: ${
|
|
2845
|
+
const chunks = chunkAt100(prep.validInputs);
|
|
2846
|
+
ctx?.logger?.info?.(`import-leads(${prep.mode}): ${prep.validInputs.length} rows \u2192 ${chunks.length} chunk(s); dry_run=${dryRun}, totalBudgetMs=${totalBudget}`);
|
|
2705
2847
|
const importIds = [];
|
|
2706
2848
|
const matched = /* @__PURE__ */ new Map();
|
|
2707
2849
|
const notImported = /* @__PURE__ */ new Map();
|
|
@@ -2713,13 +2855,9 @@ var importLeads = {
|
|
|
2713
2855
|
try {
|
|
2714
2856
|
for (let i = 0; i < chunks.length; i++) {
|
|
2715
2857
|
const chunk = chunks[i];
|
|
2716
|
-
const out = await runOneChunk(client, chunk, i, chunks.length, dryRun, perPhaseBudget, totalDeadline, ctx, signal, recordImportId);
|
|
2858
|
+
const out = await runOneChunk(client, chunk, i, chunks.length, prep.header, prep.mappings, dryRun, perPhaseBudget, totalDeadline, ctx, signal, recordImportId);
|
|
2717
2859
|
if (!dryRun) {
|
|
2718
|
-
reconcileOneChunk(
|
|
2719
|
-
} else {
|
|
2720
|
-
for (const c of chunk) {
|
|
2721
|
-
notImported.set(c.index, { domain: c.domain, reason: "dry_run" });
|
|
2722
|
-
}
|
|
2860
|
+
reconcileOneChunk(prep, out, matched, notImported);
|
|
2723
2861
|
}
|
|
2724
2862
|
}
|
|
2725
2863
|
} catch (err) {
|
|
@@ -2738,33 +2876,65 @@ var importLeads = {
|
|
|
2738
2876
|
throw err;
|
|
2739
2877
|
}
|
|
2740
2878
|
}
|
|
2741
|
-
for (const m of lookup.malformed) {
|
|
2742
|
-
notImported.set(-1 - notImported.size, { domain: m, reason: "malformed" });
|
|
2743
|
-
}
|
|
2744
2879
|
const leads = [];
|
|
2745
2880
|
const not_imported = [];
|
|
2746
2881
|
if (dryRun) {
|
|
2747
|
-
for (const inp of
|
|
2748
|
-
|
|
2882
|
+
for (const inp of prep.validInputs) {
|
|
2883
|
+
if (prep.mode === "domains") {
|
|
2884
|
+
not_imported.push({ domain: inp.outputDomain, reason: "dry_run" });
|
|
2885
|
+
} else {
|
|
2886
|
+
const entry = { rowId: inp.rowId, reason: "dry_run" };
|
|
2887
|
+
if (inp.outputDomain)
|
|
2888
|
+
entry.domain = inp.outputDomain;
|
|
2889
|
+
not_imported.push(entry);
|
|
2890
|
+
}
|
|
2749
2891
|
}
|
|
2750
2892
|
} else {
|
|
2751
|
-
for (const inp of
|
|
2893
|
+
for (const inp of prep.validInputs) {
|
|
2752
2894
|
const m = matched.get(inp.index);
|
|
2753
2895
|
if (m) {
|
|
2754
|
-
|
|
2896
|
+
if (prep.mode === "domains") {
|
|
2897
|
+
leads.push({
|
|
2898
|
+
domain: inp.outputDomain,
|
|
2899
|
+
leadId: m.leadId,
|
|
2900
|
+
name: m.name
|
|
2901
|
+
});
|
|
2902
|
+
} else {
|
|
2903
|
+
const e = {
|
|
2904
|
+
rowId: inp.rowId,
|
|
2905
|
+
leadId: m.leadId,
|
|
2906
|
+
name: m.name
|
|
2907
|
+
};
|
|
2908
|
+
if (m.domain ?? inp.outputDomain)
|
|
2909
|
+
e.domain = m.domain ?? inp.outputDomain;
|
|
2910
|
+
leads.push(e);
|
|
2911
|
+
}
|
|
2755
2912
|
continue;
|
|
2756
2913
|
}
|
|
2757
2914
|
const ni = notImported.get(inp.index);
|
|
2758
2915
|
if (ni) {
|
|
2759
|
-
|
|
2916
|
+
if (prep.mode === "domains") {
|
|
2917
|
+
not_imported.push({ domain: inp.outputDomain, reason: ni.reason });
|
|
2918
|
+
} else {
|
|
2919
|
+
const e = { rowId: inp.rowId, reason: ni.reason };
|
|
2920
|
+
if (ni.domain ?? inp.outputDomain)
|
|
2921
|
+
e.domain = ni.domain ?? inp.outputDomain;
|
|
2922
|
+
not_imported.push(e);
|
|
2923
|
+
}
|
|
2760
2924
|
continue;
|
|
2761
2925
|
}
|
|
2762
|
-
|
|
2926
|
+
if (prep.mode === "domains") {
|
|
2927
|
+
not_imported.push({ domain: inp.outputDomain, reason: "internal_error" });
|
|
2928
|
+
} else {
|
|
2929
|
+
const e = { rowId: inp.rowId, reason: "internal_error" };
|
|
2930
|
+
if (inp.outputDomain)
|
|
2931
|
+
e.domain = inp.outputDomain;
|
|
2932
|
+
not_imported.push(e);
|
|
2933
|
+
}
|
|
2763
2934
|
}
|
|
2764
2935
|
}
|
|
2765
|
-
for (const
|
|
2766
|
-
|
|
2767
|
-
not_imported.push(v);
|
|
2936
|
+
for (const m of prep.malformedDomains) {
|
|
2937
|
+
not_imported.push({ domain: m, reason: "malformed" });
|
|
2768
2938
|
}
|
|
2769
2939
|
return {
|
|
2770
2940
|
leads,
|
package/package.json
CHANGED