@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-NED7ATJI.js";
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.3.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-YMZYFHZK.js");
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-YMZYFHZK.js");
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 lines = ["MCP_ROW_ID,LEAD_NAME,LEAD_WEBSITE"];
2362
- for (const r of rows) {
2363
- lines.push([escapeCsvCell(r.rowId), escapeCsvCell(r.name), escapeCsvCell(r.website)].join(","));
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 buildInputLookup(inputs) {
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 malformed = [];
2455
+ const malformedDomains = [];
2436
2456
  const byDomain = /* @__PURE__ */ new Map();
2437
2457
  const byRowId = /* @__PURE__ */ new Map();
2438
- inputs.forEach((inp, i) => {
2439
- void i;
2440
- const norm = normalizeDomain(inp.domain ?? "");
2458
+ for (const inp of inputs) {
2459
+ const norm = normalizeDomain(inp?.domain ?? "");
2441
2460
  if (!norm) {
2442
- malformed.push(inp.domain ?? "");
2443
- return;
2461
+ malformedDomains.push(inp?.domain ?? "");
2462
+ continue;
2444
2463
  }
2445
2464
  if (byDomain.has(norm))
2446
- return;
2465
+ continue;
2447
2466
  const rowId = randomUUID();
2448
2467
  const idx = validInputs.length;
2449
- validInputs.push({ index: idx, rowId, domain: norm, name: inp.name?.trim() || norm });
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
- return { validInputs, malformed, byDomain, byRowId };
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 domains in 30s, or split the batch. importId=${importId}.`, `GET /imports/${importId}/records`);
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) => ({ rowId: c.rowId, name: c.name, website: c.domain })));
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(chunk, byRowIdGlobal, byDomainGlobal, validInputsGlobal, matched, notImported) {
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 && byRowIdGlobal.has(rowIdCell)) {
2586
- inputIdx = byRowIdGlobal.get(rowIdCell);
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 && byDomainGlobal.has(norm)) {
2593
- inputIdx = byDomainGlobal.get(norm);
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 && byDomainGlobal.has(norm)) {
2600
- inputIdx = byDomainGlobal.get(norm);
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 = validInputsGlobal[inputIdx];
2608
- notImported.set(inputIdx, { domain: inp2.domain, reason: "ambiguous" });
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 = validInputsGlobal[inputIdx];
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.domain,
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.domain, reason });
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.domain, reason: "internal_error" });
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 a list of company domains and get back stable Leadbay leadIds for downstream chaining into leadbay_bulk_qualify_leads / leadbay_research_lead.\n\n\u26A0\uFE0F MUTATES USER STATE. This tool wraps Leadbay's CRM-import wizard. 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\nReturns: leads = leadIds for domains Leadbay already knows about (via crawler). not_imported = domains Leadbay doesn't know yet, with a reason. The tool does NOT create new leads for unknown domains; the caller decides what to do.\n\nWhen to use: you have a list of domains from another system (CRM, analytics, email correspondents) and need to map them to Leadbay leadIds.\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.",
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.1.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: "List of company domains to map to Leadbay leadIds.",
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 domain format / wizard reachability without polluting the CRM."
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
- required: ["domains"]
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
- if (!Array.isArray(params.domains) || params.domains.length === 0) {
2677
- throw client.makeError("IMPORT_EMPTY_INPUT", "domains[] must contain at least one entry", "Pass at least one domain in domains[].", "POST /imports");
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 lookup = buildInputLookup(params.domains);
2684
- if (lookup.validInputs.length === 0) {
2685
- const not_imported2 = lookup.malformed.map((d) => ({
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(lookup.validInputs);
2704
- ctx?.logger?.info?.(`import-leads: ${lookup.validInputs.length} domains \u2192 ${chunks.length} chunk(s); dry_run=${dryRun}, totalBudgetMs=${totalBudget}`);
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(out, lookup.byRowId, lookup.byDomain, lookup.validInputs, matched, notImported);
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 lookup.validInputs) {
2748
- not_imported.push({ domain: inp.domain, reason: "dry_run" });
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 lookup.validInputs) {
2893
+ for (const inp of prep.validInputs) {
2752
2894
  const m = matched.get(inp.index);
2753
2895
  if (m) {
2754
- leads.push(m);
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
- not_imported.push(ni);
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
- not_imported.push({ domain: inp.domain, reason: "internal_error" });
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 [k, v] of notImported) {
2766
- if (k < 0)
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,
@@ -71,7 +71,7 @@ import {
71
71
  tools,
72
72
  updateLens,
73
73
  updateLensFilter
74
- } from "./chunk-NED7ATJI.js";
74
+ } from "./chunk-O2UOXRZO.js";
75
75
  export {
76
76
  InMemoryBulkStore,
77
77
  LeadbayClient,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@leadbay/mcp",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "Model Context Protocol (MCP) server for Leadbay — AI lead discovery, qualification, and enrichment for Claude Desktop, Cursor, and Claude Code.",
5
5
  "type": "module",
6
6
  "bin": {