@leadbay/mcp 0.6.2 → 0.6.3

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,6 +1,12 @@
1
1
  # Changelog — @leadbay/mcp
2
2
 
3
- ## 0.6.2UNRELEASED
3
+ ## 0.6.32026-05-12
4
+
5
+ **Async import schema fix**: `leadbay_import_leads` now declares both its legacy blocking result shape and its async kickoff shape (`{status: "running", handle_id, importIds, progress}`) in `outputSchema`, so Claude Desktop and other MCP SDK clients accept the fast handle response instead of rejecting `structuredContent`.
6
+
7
+ **Async qualification schema fix**: `leadbay_bulk_qualify_leads` now also declares its async kickoff shape (`{status: "running", handle_id, qualify_id, ...}`), matching the `wait_for_completion:false` behavior added for short MCP client transport timeouts.
8
+
9
+ ## 0.6.2 — 2026-05-12
4
10
 
5
11
  **MCPB install fix**: desktop extension bundles now use the current `manifest_version` field and remove unsupported manifest keys (`user_config.*.enum` and an internal note field) so Claude Desktop can preview and install the MCPB.
6
12
 
package/dist/bin.js CHANGED
@@ -8,7 +8,7 @@ import {
8
8
  granularReadTools,
9
9
  granularWriteTools,
10
10
  resolveRegion
11
- } from "./chunk-NLG7GUZ3.js";
11
+ } from "./chunk-QAOJARMK.js";
12
12
 
13
13
  // src/bin.ts
14
14
  import { realpathSync } from "fs";
@@ -587,7 +587,7 @@ function buildServer(client, opts = {}) {
587
587
 
588
588
  // src/bin.ts
589
589
  import { createRequire } from "module";
590
- var VERSION = "0.6.2";
590
+ var VERSION = "0.6.3";
591
591
  var HELP = `
592
592
  leadbay-mcp ${VERSION} \u2014 Leadbay Model Context Protocol server
593
593
 
@@ -876,7 +876,7 @@ async function runLogin(args) {
876
876
  let result;
877
877
  try {
878
878
  if (pinnedRegion && !allowFallback) {
879
- const { REGIONS } = await import("./dist-JUTSXWBL.js");
879
+ const { REGIONS } = await import("./dist-KHXRELXF.js");
880
880
  const baseUrl = REGIONS[pinnedRegion];
881
881
  const c = createClient({ region: pinnedRegion });
882
882
  const token = await loginAt(baseUrl, email, password);
@@ -1368,7 +1368,7 @@ leadbay-mcp install \u2014 detected MCP clients on this machine:
1368
1368
  let region;
1369
1369
  try {
1370
1370
  if (pinnedRegion && !allowFallback) {
1371
- const { REGIONS } = await import("./dist-JUTSXWBL.js");
1371
+ const { REGIONS } = await import("./dist-KHXRELXF.js");
1372
1372
  const baseUrl = REGIONS[pinnedRegion];
1373
1373
  token = await loginAt(baseUrl, email, password);
1374
1374
  region = pinnedRegion;
@@ -1871,7 +1871,10 @@ async function refreshLeadStates(client, leadIds, questionOrder) {
1871
1871
  }
1872
1872
 
1873
1873
  // ../core/dist/composite/import-leads.js
1874
- import { randomUUID } from "crypto";
1874
+ import { createHash as createHash2, randomUUID } from "crypto";
1875
+ function isImportLeadsRunningResult(result) {
1876
+ return "status" in result && result.status === "running";
1877
+ }
1875
1878
  var CHUNK_SIZE = 100;
1876
1879
  var POLL_INTERVAL_MS2 = 2e3;
1877
1880
  var DEFAULT_PER_PHASE_BUDGET_MS = 6e4;
@@ -1972,6 +1975,29 @@ function chunkAt100(items) {
1972
1975
  }
1973
1976
  return chunks;
1974
1977
  }
1978
+ function stableStringify(value) {
1979
+ if (value === null || typeof value !== "object")
1980
+ return JSON.stringify(value);
1981
+ if (Array.isArray(value))
1982
+ return `[${value.map(stableStringify).join(",")}]`;
1983
+ const obj = value;
1984
+ return `{${Object.keys(obj).sort().map((k) => `${JSON.stringify(k)}:${stableStringify(obj[k])}`).join(",")}}`;
1985
+ }
1986
+ function importFingerprint(params, prep) {
1987
+ const payload = {
1988
+ mode: prep.mode,
1989
+ rows: prep.validInputs.map((i) => ({
1990
+ domain: i.domain,
1991
+ outputDomain: i.outputDomain,
1992
+ row: i.row
1993
+ })),
1994
+ malformed: prep.malformedDomains,
1995
+ header: prep.header,
1996
+ mappings: prep.mappings,
1997
+ dry_run: Boolean(params.dry_run)
1998
+ };
1999
+ return createHash2("sha256").update(stableStringify(payload)).digest("hex");
2000
+ }
1975
2001
  function checkAborted(signal) {
1976
2002
  if (signal?.aborted) {
1977
2003
  throw Object.assign(new Error("aborted"), { name: "AbortError" });
@@ -2339,6 +2365,10 @@ async function pollRecordsToTerminal(client, importId, budgetMs, expectedRowCoun
2339
2365
  }
2340
2366
  }
2341
2367
  async function runOneChunk(client, chunk, chunkIdx, totalChunks, header, mappings, dryRun, perPhaseBudgetMs, totalDeadline, ctx, signal, onImportId) {
2368
+ const upload = await uploadOneChunk(client, chunk, chunkIdx, totalChunks, header, ctx, onImportId);
2369
+ return completeUploadedChunk(client, upload, mappings, dryRun, perPhaseBudgetMs, totalDeadline, ctx, signal);
2370
+ }
2371
+ async function uploadOneChunk(client, chunk, chunkIdx, totalChunks, header, ctx, onImportId) {
2342
2372
  const csv = synthesizeCsv(header, chunk.map((c) => c.row));
2343
2373
  const ts = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
2344
2374
  const fileName = `mcp-import-${ts}-${chunkIdx}.csv`;
@@ -2346,6 +2376,10 @@ async function runOneChunk(client, chunk, chunkIdx, totalChunks, header, mapping
2346
2376
  const upload = await client.requestRawBinary("POST", `/imports?file_name=${encodeURIComponent(fileName)}`, "text/csv", csv);
2347
2377
  const importId = upload.id;
2348
2378
  onImportId(importId);
2379
+ return { importId, chunk, chunkIdx, totalChunks };
2380
+ }
2381
+ async function completeUploadedChunk(client, upload, mappings, dryRun, perPhaseBudgetMs, totalDeadline, ctx, signal) {
2382
+ const { importId, chunk } = upload;
2349
2383
  const phaseBudget = Math.min(perPhaseBudgetMs, Math.max(1, totalDeadline - Date.now()));
2350
2384
  await pollPreprocess(client, importId, phaseBudget, ctx, signal);
2351
2385
  ctx?.logger?.info?.(`import-leads: preprocess done for importId=${importId}`);
@@ -2416,6 +2450,82 @@ function reconcileOneChunk(prep, chunk, matched, notImported) {
2416
2450
  }
2417
2451
  }
2418
2452
  }
2453
+ function buildImportLeadsResult(client, prep, importIds, matched, notImported, dryRun, cancelled) {
2454
+ const leads = [];
2455
+ const not_imported = [];
2456
+ if (dryRun) {
2457
+ for (const inp of prep.validInputs) {
2458
+ if (prep.mode === "domains") {
2459
+ not_imported.push({ domain: inp.outputDomain, reason: "dry_run" });
2460
+ } else {
2461
+ const entry = { rowId: inp.rowId, reason: "dry_run" };
2462
+ if (inp.outputDomain)
2463
+ entry.domain = inp.outputDomain;
2464
+ not_imported.push(entry);
2465
+ }
2466
+ }
2467
+ } else {
2468
+ for (const inp of prep.validInputs) {
2469
+ const m = matched.get(inp.index);
2470
+ if (m) {
2471
+ if (prep.mode === "domains") {
2472
+ leads.push({
2473
+ domain: inp.outputDomain,
2474
+ leadId: m.leadId,
2475
+ name: m.name
2476
+ });
2477
+ } else {
2478
+ const e = {
2479
+ rowId: inp.rowId,
2480
+ leadId: m.leadId,
2481
+ name: m.name
2482
+ };
2483
+ if (m.domain ?? inp.outputDomain)
2484
+ e.domain = m.domain ?? inp.outputDomain;
2485
+ leads.push(e);
2486
+ }
2487
+ continue;
2488
+ }
2489
+ const ni = notImported.get(inp.index);
2490
+ if (ni) {
2491
+ if (prep.mode === "domains") {
2492
+ not_imported.push({ domain: inp.outputDomain, reason: ni.reason });
2493
+ } else {
2494
+ const e = { rowId: inp.rowId, reason: ni.reason };
2495
+ if (ni.domain ?? inp.outputDomain)
2496
+ e.domain = ni.domain ?? inp.outputDomain;
2497
+ not_imported.push(e);
2498
+ }
2499
+ continue;
2500
+ }
2501
+ if (prep.mode === "domains") {
2502
+ not_imported.push({ domain: inp.outputDomain, reason: "internal_error" });
2503
+ } else {
2504
+ const e = { rowId: inp.rowId, reason: "internal_error" };
2505
+ if (inp.outputDomain)
2506
+ e.domain = inp.outputDomain;
2507
+ not_imported.push(e);
2508
+ }
2509
+ }
2510
+ }
2511
+ for (const m of prep.malformedDomains) {
2512
+ not_imported.push({ domain: m, reason: "malformed" });
2513
+ }
2514
+ return {
2515
+ leads,
2516
+ not_imported,
2517
+ importIds,
2518
+ region: client.region,
2519
+ cancelled: cancelled || void 0,
2520
+ dry_run: dryRun || void 0,
2521
+ _meta: client.lastMeta ?? {
2522
+ region: client.region,
2523
+ endpoint: "POST /imports",
2524
+ latency_ms: null,
2525
+ retry_after: null
2526
+ }
2527
+ };
2528
+ }
2419
2529
  var importLeads = {
2420
2530
  name: "leadbay_import_leads",
2421
2531
  annotations: {
@@ -2428,7 +2538,7 @@ var importLeads = {
2428
2538
  idempotentHint: true,
2429
2539
  openWorldHint: true
2430
2540
  },
2431
- 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\n\u2139\uFE0F Monitor-tab membership: imported leads are NOT auto-promoted to the user's Monitor view. Lens-scoring decides \u2014 only above-threshold leads get `in_monitor: true` server-side.\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\nCustom fields: pass org-defined custom field mappings as 'CUSTOM.<id>' (raw wire format) in `mappings.fields`, OR use the ergonomic `mappings.custom_fields` shorthand: `{ColName: 8}` (numeric id) or `{ColName: 'priority_test'}` (field name). Discover available custom fields via leadbay_list_mappable_fields.\n\nRequires: LEADBAY_MCP_WRITE=1 (MCP) or exposeWrite=true (OpenClaw); admin role on the Leadbay account; active billing.",
2541
+ 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. For MCP clients with short transport timeouts, pass `wait_for_completion:false` to return quickly with `{status:'running', handle_id}`; poll `leadbay_import_status` with that handle for progress and the final `{leads, not_imported, importIds}` result.\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\n\u2139\uFE0F Monitor-tab membership: imported leads are NOT auto-promoted to the user's Monitor view. Lens-scoring decides \u2014 only above-threshold leads get `in_monitor: true` server-side.\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\nCustom fields: pass org-defined custom field mappings as 'CUSTOM.<id>' (raw wire format) in `mappings.fields`, OR use the ergonomic `mappings.custom_fields` shorthand: `{ColName: 8}` (numeric id) or `{ColName: 'priority_test'}` (field name). Discover available custom fields via leadbay_list_mappable_fields.\n\nRequires: LEADBAY_MCP_WRITE=1 (MCP) or exposeWrite=true (OpenClaw); admin role on the Leadbay account; active billing.",
2432
2542
  write: true,
2433
2543
  version: "0.3.0",
2434
2544
  inputSchema: {
@@ -2494,6 +2604,10 @@ var importLeads = {
2494
2604
  total_budget_ms: {
2495
2605
  type: "number",
2496
2606
  description: `Overall cap across all phases (default ${DEFAULT_TOTAL_BUDGET_MS}ms).`
2607
+ },
2608
+ wait_for_completion: {
2609
+ type: "boolean",
2610
+ description: "When false, validate and enqueue the import in the background, then return `{status:'running', handle_id}` immediately. Poll leadbay_import_status(handle_id). Default is true for 0.6.x backwards compatibility."
2497
2611
  }
2498
2612
  },
2499
2613
  // Neither field is "required" at the schema level; xor + presence is
@@ -2508,6 +2622,18 @@ var importLeads = {
2508
2622
  description: "Imported leads. Domains mode: [{domain, leadId, name}]. Records mode: [{rowId, domain?, leadId, name}].",
2509
2623
  items: { type: "object" }
2510
2624
  },
2625
+ status: {
2626
+ type: "string",
2627
+ description: "`running` when wait_for_completion=false; absent on the legacy blocking result."
2628
+ },
2629
+ handle_id: {
2630
+ type: "string",
2631
+ description: "Persisted UUID handle to pass to leadbay_import_status."
2632
+ },
2633
+ progress: {
2634
+ type: "object",
2635
+ description: "Current async import progress when wait_for_completion=false."
2636
+ },
2511
2637
  not_imported: {
2512
2638
  type: "array",
2513
2639
  description: "Inputs that did NOT yield a leadId. Each entry has a `reason` ('malformed', 'NO_MATCH', 'TIMEOUT', etc.) plus the input echo.",
@@ -2529,7 +2655,11 @@ var importLeads = {
2529
2655
  },
2530
2656
  _meta: { type: "object" }
2531
2657
  },
2532
- required: ["leads", "not_imported", "importIds", "region", "_meta"]
2658
+ required: ["importIds", "region", "_meta"],
2659
+ anyOf: [
2660
+ { required: ["leads", "not_imported", "importIds", "region", "_meta"] },
2661
+ { required: ["status", "handle_id", "importIds", "progress", "region", "_meta"] }
2662
+ ]
2533
2663
  },
2534
2664
  execute: async (client, params, ctx) => {
2535
2665
  const signal = ctx?.signal;
@@ -2537,6 +2667,7 @@ var importLeads = {
2537
2667
  const perPhaseBudget = params.per_phase_budget_ms ?? DEFAULT_PER_PHASE_BUDGET_MS;
2538
2668
  const totalBudget = params.total_budget_ms ?? DEFAULT_TOTAL_BUDGET_MS;
2539
2669
  const totalDeadline = Date.now() + totalBudget;
2670
+ const waitForCompletion = params.wait_for_completion ?? true;
2540
2671
  const hasDomains = Array.isArray(params.domains) && params.domains.length > 0;
2541
2672
  const hasRecords = Array.isArray(params.records) && params.records.length > 0;
2542
2673
  if (hasDomains && hasRecords) {
@@ -2563,13 +2694,13 @@ var importLeads = {
2563
2694
  }
2564
2695
  const prep = hasDomains ? prepareDomainsMode(client, params.domains) : prepareRecordsMode(client, params.records, params.mappings, customFieldCatalog);
2565
2696
  if (prep.validInputs.length === 0) {
2566
- const not_imported2 = prep.malformedDomains.map((d) => ({
2697
+ const not_imported = prep.malformedDomains.map((d) => ({
2567
2698
  domain: d,
2568
2699
  reason: "malformed"
2569
2700
  }));
2570
2701
  return {
2571
2702
  leads: [],
2572
- not_imported: not_imported2,
2703
+ not_imported,
2573
2704
  importIds: [],
2574
2705
  region: client.region,
2575
2706
  dry_run: dryRun || void 0,
@@ -2582,6 +2713,67 @@ var importLeads = {
2582
2713
  };
2583
2714
  }
2584
2715
  const chunks = chunkAt100(prep.validInputs);
2716
+ if (!waitForCompletion) {
2717
+ if (!ctx?.bulkTracker) {
2718
+ throw client.makeError("BULK_TRACKER_UNAVAILABLE", "No BulkTracker configured on this MCP instance", "leadbay_import_leads wait_for_completion=false needs a BulkTracker so the handle survives restart.", "");
2719
+ }
2720
+ const reservation = await ctx.bulkTracker.findOrCreatePendingImport({
2721
+ import_fingerprint: importFingerprint(params, prep),
2722
+ mode: prep.mode,
2723
+ dry_run: dryRun,
2724
+ records_total: prep.validInputs.length
2725
+ });
2726
+ const importIds2 = [...reservation.record.import_ids];
2727
+ const uploadedChunks = [];
2728
+ if (!reservation.reused || reservation.record.import_ids.length === 0) {
2729
+ try {
2730
+ for (let i = 0; i < chunks.length; i++) {
2731
+ const upload = await uploadOneChunk(client, chunks[i], i, chunks.length, prep.header, ctx, (id) => {
2732
+ if (!importIds2.includes(id))
2733
+ importIds2.push(id);
2734
+ });
2735
+ uploadedChunks.push(upload);
2736
+ await ctx.bulkTracker.setImportIds(reservation.record.bulk_id, importIds2);
2737
+ }
2738
+ await ctx.bulkTracker.setImportProgress(reservation.record.bulk_id, {
2739
+ phase: "preprocess",
2740
+ records_processed: 0,
2741
+ records_total: prep.validInputs.length
2742
+ });
2743
+ } catch (err) {
2744
+ await ctx.bulkTracker.markImportFailed(reservation.record.bulk_id, err?.message ?? err?.code ?? "unknown");
2745
+ throw err;
2746
+ }
2747
+ }
2748
+ if (uploadedChunks.length > 0) {
2749
+ void runImportInBackground(client, prep, uploadedChunks, {
2750
+ dryRun,
2751
+ perPhaseBudget,
2752
+ totalBudget
2753
+ }, ctx, reservation.record.bulk_id);
2754
+ }
2755
+ return {
2756
+ status: "running",
2757
+ handle_id: reservation.record.bulk_id,
2758
+ importIds: importIds2,
2759
+ progress: {
2760
+ phase: reservation.record.status === "complete" ? "complete" : importIds2.length > 0 ? "preprocess" : "queued",
2761
+ records_processed: reservation.record.status === "complete" ? reservation.record.records_total : 0,
2762
+ records_total: reservation.record.records_total
2763
+ },
2764
+ region: client.region,
2765
+ ...reservation.reused ? {
2766
+ reused: true,
2767
+ seconds_since_original: reservation.seconds_since_original
2768
+ } : {},
2769
+ _meta: client.lastMeta ?? {
2770
+ region: client.region,
2771
+ endpoint: "POST /imports",
2772
+ latency_ms: null,
2773
+ retry_after: null
2774
+ }
2775
+ };
2776
+ }
2585
2777
  ctx?.logger?.info?.(`import-leads(${prep.mode}): ${prep.validInputs.length} rows \u2192 ${chunks.length} chunk(s); dry_run=${dryRun}, totalBudgetMs=${totalBudget}`);
2586
2778
  const importIds = [];
2587
2779
  const matched = /* @__PURE__ */ new Map();
@@ -2615,82 +2807,45 @@ var importLeads = {
2615
2807
  throw err;
2616
2808
  }
2617
2809
  }
2618
- const leads = [];
2619
- const not_imported = [];
2620
- if (dryRun) {
2621
- for (const inp of prep.validInputs) {
2622
- if (prep.mode === "domains") {
2623
- not_imported.push({ domain: inp.outputDomain, reason: "dry_run" });
2624
- } else {
2625
- const entry = { rowId: inp.rowId, reason: "dry_run" };
2626
- if (inp.outputDomain)
2627
- entry.domain = inp.outputDomain;
2628
- not_imported.push(entry);
2629
- }
2630
- }
2631
- } else {
2632
- for (const inp of prep.validInputs) {
2633
- const m = matched.get(inp.index);
2634
- if (m) {
2635
- if (prep.mode === "domains") {
2636
- leads.push({
2637
- domain: inp.outputDomain,
2638
- leadId: m.leadId,
2639
- name: m.name
2640
- });
2641
- } else {
2642
- const e = {
2643
- rowId: inp.rowId,
2644
- leadId: m.leadId,
2645
- name: m.name
2646
- };
2647
- if (m.domain ?? inp.outputDomain)
2648
- e.domain = m.domain ?? inp.outputDomain;
2649
- leads.push(e);
2650
- }
2651
- continue;
2652
- }
2653
- const ni = notImported.get(inp.index);
2654
- if (ni) {
2655
- if (prep.mode === "domains") {
2656
- not_imported.push({ domain: inp.outputDomain, reason: ni.reason });
2657
- } else {
2658
- const e = { rowId: inp.rowId, reason: ni.reason };
2659
- if (ni.domain ?? inp.outputDomain)
2660
- e.domain = ni.domain ?? inp.outputDomain;
2661
- not_imported.push(e);
2810
+ return buildImportLeadsResult(client, prep, importIds, matched, notImported, dryRun, cancelled);
2811
+ }
2812
+ };
2813
+ async function runImportInBackground(client, prep, uploadedChunks, opts, ctx, handleId) {
2814
+ const tracker = ctx.bulkTracker;
2815
+ if (!tracker)
2816
+ return;
2817
+ void tracker.setImportProgress(handleId, {
2818
+ phase: "preprocess",
2819
+ records_processed: 0,
2820
+ records_total: prep.validInputs.length
2821
+ }).catch(() => {
2822
+ });
2823
+ setTimeout(() => {
2824
+ void (async () => {
2825
+ const bgCtx = { logger: ctx.logger, bulkTracker: tracker };
2826
+ const importIds = uploadedChunks.map((chunk) => chunk.importId);
2827
+ const matched = /* @__PURE__ */ new Map();
2828
+ const notImported = /* @__PURE__ */ new Map();
2829
+ try {
2830
+ const totalDeadline = Date.now() + opts.totalBudget;
2831
+ for (const upload of uploadedChunks) {
2832
+ const out = await completeUploadedChunk(client, upload, prep.mappings, opts.dryRun, opts.perPhaseBudget, totalDeadline, bgCtx, void 0);
2833
+ if (!opts.dryRun) {
2834
+ reconcileOneChunk(prep, out, matched, notImported);
2662
2835
  }
2663
- continue;
2664
- }
2665
- if (prep.mode === "domains") {
2666
- not_imported.push({ domain: inp.outputDomain, reason: "internal_error" });
2667
- } else {
2668
- const e = { rowId: inp.rowId, reason: "internal_error" };
2669
- if (inp.outputDomain)
2670
- e.domain = inp.outputDomain;
2671
- not_imported.push(e);
2672
2836
  }
2837
+ const result = buildImportLeadsResult(client, prep, importIds, matched, notImported, opts.dryRun, false);
2838
+ await tracker.markImportComplete(handleId, {
2839
+ leads: result.leads,
2840
+ not_imported: result.not_imported,
2841
+ importIds: result.importIds
2842
+ });
2843
+ } catch (err) {
2844
+ await tracker.markImportFailed(handleId, err?.message ?? err?.code ?? "unknown");
2673
2845
  }
2674
- }
2675
- for (const m of prep.malformedDomains) {
2676
- not_imported.push({ domain: m, reason: "malformed" });
2677
- }
2678
- return {
2679
- leads,
2680
- not_imported,
2681
- importIds,
2682
- region: client.region,
2683
- cancelled: cancelled || void 0,
2684
- dry_run: dryRun || void 0,
2685
- _meta: client.lastMeta ?? {
2686
- region: client.region,
2687
- endpoint: "POST /imports",
2688
- latency_ms: null,
2689
- retry_after: null
2690
- }
2691
- };
2692
- }
2693
- };
2846
+ })();
2847
+ }, 0);
2848
+ }
2694
2849
 
2695
2850
  // ../core/dist/tools/list-mappable-fields.js
2696
2851
  var STANDARD_FIELDS = [
@@ -4490,7 +4645,7 @@ var bulkQualifyLeads = {
4490
4645
  idempotentHint: true,
4491
4646
  openWorldHint: true
4492
4647
  },
4493
- description: "Pick the next N unqualified leads in the active lens and qualify them (run AI rescore + web fetch), polling until the answers are populated or a budget is exhausted. Already-qualified leads (those with a non-null ai_agent_lead_score) are silently no-ops on the backend, so this composite paginates past them to find fresh candidates. On 429 mid-fanout, stops launching but keeps polling already-launched leads. Context: Leadbay auto-qualifies roughly the top 10 of each daily batch. Leads below the top ~10 are NOT worse \u2014 the system is saving resources. This tool is how the agent spends more resources to go deeper on promising-looking leads the user hasn't had time to surface yet. When to use: when the user wants more qualified leads than what's currently shown, or when a lead looks promising in leadbay_pull_leads but has an empty qualification_summary. When NOT to use: to qualify a single specific lead \u2014 that's leadbay_qualify_lead (granular, advanced).",
4648
+ description: "Pick the next N unqualified leads in the active lens and qualify them (run AI rescore + web fetch). Pass `wait_for_completion:false` to return quickly with `{status:'running', qualify_id}`; poll leadbay_qualify_status with that id. With wait_for_completion omitted/true, the legacy behavior polls until the answers are populated or a budget is exhausted. Already-qualified leads (those with a non-null ai_agent_lead_score) are silently no-ops on the backend, so this composite paginates past them to find fresh candidates. On 429 mid-fanout, stops launching but keeps polling already-launched leads. Context: Leadbay auto-qualifies roughly the top 10 of each daily batch. Leads below the top ~10 are NOT worse \u2014 the system is saving resources. This tool is how the agent spends more resources to go deeper on promising-looking leads the user hasn't had time to surface yet. When to use: when the user wants more qualified leads than what's currently shown, or when a lead looks promising in leadbay_pull_leads but has an empty qualification_summary. When NOT to use: to qualify a single specific lead \u2014 that's leadbay_qualify_lead (granular, advanced).",
4494
4649
  inputSchema: {
4495
4650
  type: "object",
4496
4651
  properties: {
@@ -4514,6 +4669,10 @@ var bulkQualifyLeads = {
4514
4669
  total_budget_ms: {
4515
4670
  type: "number",
4516
4671
  description: `Total polling budget in ms (default ${DEFAULT_TOTAL_BUDGET_MS2})`
4672
+ },
4673
+ wait_for_completion: {
4674
+ type: "boolean",
4675
+ description: "When false, launch qualification and return `{status:'running', qualify_id}` immediately. Poll leadbay_qualify_status. Default is true for 0.6.x backwards compatibility."
4517
4676
  }
4518
4677
  },
4519
4678
  additionalProperties: false
@@ -4526,6 +4685,14 @@ var bulkQualifyLeads = {
4526
4685
  description: "Leads whose qualification finished within budget. Each entry: lead_id, qualification_summary{answered,total,avg_qualification_boost}, signals_count.",
4527
4686
  items: { type: "object" }
4528
4687
  },
4688
+ status: {
4689
+ type: "string",
4690
+ description: "`running` when wait_for_completion=false; absent on the legacy blocking result."
4691
+ },
4692
+ handle_id: { type: "string", description: "Alias of qualify_id for handle-oriented callers." },
4693
+ qualify_id: { type: "string", description: "UUIDv4 to poll via leadbay_qualify_status." },
4694
+ lead_ids: { type: "array", items: { type: "string" } },
4695
+ launched_count: { type: "number" },
4529
4696
  still_running: {
4530
4697
  type: "array",
4531
4698
  description: "Leads launched but whose qualification did not complete within budget. Re-poll via leadbay_qualify_status with the bulk_id (when present).",
@@ -4556,13 +4723,30 @@ var bulkQualifyLeads = {
4556
4723
  properties: { region: { type: "string" } }
4557
4724
  }
4558
4725
  },
4559
- required: ["qualified", "still_running", "failed", "quota_exceeded"]
4726
+ required: ["failed", "quota_exceeded"],
4727
+ anyOf: [
4728
+ { required: ["qualified", "still_running", "failed", "quota_exceeded"] },
4729
+ {
4730
+ required: [
4731
+ "status",
4732
+ "handle_id",
4733
+ "qualify_id",
4734
+ "lead_ids",
4735
+ "launched_count",
4736
+ "failed",
4737
+ "quota_exceeded",
4738
+ "lens_id",
4739
+ "_meta"
4740
+ ]
4741
+ }
4742
+ ]
4560
4743
  },
4561
4744
  execute: async (client, params, ctx) => {
4562
4745
  const wantCount = Math.min(params.count ?? DEFAULT_COUNT, MAX_COUNT);
4563
4746
  const perLeadBudget = params.per_lead_budget_ms ?? DEFAULT_PER_LEAD_BUDGET_MS;
4564
4747
  const totalBudget = params.total_budget_ms ?? DEFAULT_TOTAL_BUDGET_MS2;
4565
4748
  const totalDeadline = Date.now() + totalBudget;
4749
+ const waitForCompletion = params.wait_for_completion ?? true;
4566
4750
  let candidates;
4567
4751
  let exhausted = false;
4568
4752
  let totalUnqualifiedFound = 0;
@@ -4605,6 +4789,58 @@ var bulkQualifyLeads = {
4605
4789
  message: "No unqualified leads found in this lens \u2014 either all leads have been qualified, or the wishlist is still computing (check leadbay_account_status for computing_wishlist)."
4606
4790
  };
4607
4791
  }
4792
+ if (!waitForCompletion) {
4793
+ if (!ctx?.bulkTracker) {
4794
+ throw client.makeError("BULK_TRACKER_UNAVAILABLE", "No BulkTracker configured on this MCP instance", "leadbay_bulk_qualify_leads wait_for_completion=false needs a BulkTracker so qualify_id survives restart.", "");
4795
+ }
4796
+ const reservation = await ctx.bulkTracker.findOrCreatePendingQualify({
4797
+ lead_ids: candidates,
4798
+ import_ids: [],
4799
+ lens_id: lensId,
4800
+ mapping_fingerprint: "bulk_qualify_leads",
4801
+ per_lead_budget_ms: perLeadBudget,
4802
+ total_budget_ms: totalBudget
4803
+ });
4804
+ const launched2 = [];
4805
+ const failed2 = [];
4806
+ let quotaExceeded2 = false;
4807
+ if (!reservation.reused) {
4808
+ for (const leadId of candidates) {
4809
+ if (quotaExceeded2)
4810
+ break;
4811
+ try {
4812
+ await client.requestVoid("POST", `/leads/${leadId}/web_fetch?force_fetch=false`);
4813
+ launched2.push(leadId);
4814
+ } catch (err) {
4815
+ if (err?.code === "QUOTA_EXCEEDED") {
4816
+ quotaExceeded2 = true;
4817
+ } else if (err?.code === "NOT_FOUND") {
4818
+ failed2.push({ lead_id: leadId, error: "lead not found" });
4819
+ } else {
4820
+ failed2.push({
4821
+ lead_id: leadId,
4822
+ error: err?.message ?? err?.code ?? "unknown"
4823
+ });
4824
+ }
4825
+ }
4826
+ }
4827
+ if (failed2.length === candidates.length || launched2.length > 0 || quotaExceeded2) {
4828
+ await ctx.bulkTracker.markLaunched(reservation.record.bulk_id);
4829
+ }
4830
+ }
4831
+ const out = {
4832
+ status: "running",
4833
+ handle_id: reservation.record.bulk_id,
4834
+ qualify_id: reservation.record.bulk_id,
4835
+ lead_ids: candidates,
4836
+ launched_count: reservation.reused ? reservation.record.lead_ids.length : launched2.length,
4837
+ failed: failed2,
4838
+ quota_exceeded: quotaExceeded2,
4839
+ lens_id: lensId,
4840
+ _meta: { region: client.region }
4841
+ };
4842
+ return out;
4843
+ }
4608
4844
  const launched = [];
4609
4845
  const failed = [];
4610
4846
  let quotaExceeded = false;
@@ -4799,7 +5035,7 @@ var importAndQualify = {
4799
5035
  idempotentHint: true,
4800
5036
  openWorldHint: true
4801
5037
  },
4802
- description: `Composite: import a list of leads (CSV-shaped records OR a list of domains), then trigger Leadbay's AI qualification (web research + per-question scoring) on every imported leadId, and return both the import outcome and the per-lead qualification answers \u2014 in one call. Honours a total wall-clock budget; when the budget is exhausted before all leads finish qualifying, returns a \`qualify_id\` UUID handle that survives MCP restart and can be passed to leadbay_qualify_status to retrieve the rest of the answers later.
5038
+ description: `Composite: import a list of leads (CSV-shaped records OR a list of domains), then trigger Leadbay's AI qualification (web research + per-question scoring) on every imported leadId, and return both the import outcome and the per-lead qualification answers \u2014 in one call. For MCP clients with short transport timeouts, pass \`wait_for_completion:false\`; the tool returns quickly with an import \`handle_id\` that can be polled with leadbay_import_status before continuing qualification. Honours a total wall-clock budget; when the budget is exhausted before all leads finish qualifying, returns a \`qualify_id\` UUID handle that survives MCP restart and can be passed to leadbay_qualify_status to retrieve the rest of the answers later.
4803
5039
 
4804
5040
  Inputs:
4805
5041
  - \`domains\`: list of \`{domain, name?}\` (Mode A) \u2014 mutually exclusive with \`records\`.
@@ -4887,6 +5123,10 @@ Requires: LEADBAY_MCP_WRITE=1 (MCP) or exposeWrite=true (OpenClaw); admin role;
4887
5123
  type: "number",
4888
5124
  description: `Per-phase budget for the import wizard (default ${DEFAULT_PER_PHASE_BUDGET_MS2}); mirrors leadbay_import_leads.`
4889
5125
  },
5126
+ wait_for_completion: {
5127
+ type: "boolean",
5128
+ description: "When false, enqueue the import phase and return `{kind:'result', status:'running', handle_id}` immediately. Poll leadbay_import_status. Default is true for 0.6.x backwards compatibility."
5129
+ },
4890
5130
  lensId: {
4891
5131
  type: "number",
4892
5132
  description: "Lens id (escape hatch \u2014 defaults to active)."
@@ -4909,6 +5149,14 @@ Requires: LEADBAY_MCP_WRITE=1 (MCP) or exposeWrite=true (OpenClaw); admin role;
4909
5149
  type: "string",
4910
5150
  description: "'result' (full flow) or 'preview' (dry_run='preview' mapping diagnostics)."
4911
5151
  },
5152
+ status: {
5153
+ type: "string",
5154
+ description: "`running` when wait_for_completion=false."
5155
+ },
5156
+ handle_id: {
5157
+ type: "string",
5158
+ description: "Import handle to pass to leadbay_import_status when wait_for_completion=false."
5159
+ },
4912
5160
  // preview-shape keys
4913
5161
  mapping_hints: {
4914
5162
  type: "array",
@@ -5015,19 +5263,76 @@ Requires: LEADBAY_MCP_WRITE=1 (MCP) or exposeWrite=true (OpenClaw); admin role;
5015
5263
  if (!ctx?.bulkTracker) {
5016
5264
  throw client.makeError("BULK_TRACKER_UNAVAILABLE", "No BulkTracker configured on this MCP instance", "leadbay_import_and_qualify needs a BulkTracker (qualify_id persistence). Upgrade to @leadbay/mcp \u22650.5.0 or set LEADBAY_BULK_STORE_ALLOW_MEMORY=1.", "");
5017
5265
  }
5266
+ if (params.wait_for_completion === false) {
5267
+ const queued = await importLeads.execute(client, {
5268
+ domains: params.domains,
5269
+ records: params.records,
5270
+ mappings: params.mappings,
5271
+ per_phase_budget_ms: perPhaseBudget,
5272
+ total_budget_ms: totalBudget,
5273
+ ...params.dry_run === true ? { dry_run: true } : {},
5274
+ wait_for_completion: false
5275
+ }, ctx);
5276
+ if (!isImportLeadsRunningResult(queued)) {
5277
+ return {
5278
+ kind: "result",
5279
+ ...chosenBudgets ? { chosen_budgets: chosenBudgets } : {},
5280
+ qualify_id: null,
5281
+ import_ids: queued.importIds,
5282
+ imported: queued.leads.map((l) => ({
5283
+ leadId: l.leadId,
5284
+ ...l.domain ? { domain: l.domain } : {},
5285
+ name: l.name ?? null,
5286
+ ...l.rowId ? { rowId: l.rowId } : {}
5287
+ })),
5288
+ not_imported: queued.not_imported.map(toNotImportedEntry),
5289
+ qualified: [],
5290
+ still_running: [],
5291
+ failed: [],
5292
+ quota_exceeded: false,
5293
+ skipped_already_qualified: [],
5294
+ not_in_lens: [],
5295
+ region: client.region,
5296
+ _meta: queued._meta
5297
+ };
5298
+ }
5299
+ return {
5300
+ kind: "result",
5301
+ status: "running",
5302
+ handle_id: queued.handle_id,
5303
+ ...chosenBudgets ? { chosen_budgets: chosenBudgets } : {},
5304
+ qualify_id: null,
5305
+ import_ids: queued.importIds,
5306
+ imported: [],
5307
+ not_imported: [],
5308
+ qualified: [],
5309
+ still_running: [],
5310
+ failed: [],
5311
+ quota_exceeded: false,
5312
+ skipped_already_qualified: [],
5313
+ not_in_lens: [],
5314
+ region: client.region,
5315
+ _meta: queued._meta
5316
+ };
5317
+ }
5018
5318
  ctx?.progress?.({
5019
5319
  progress: 1,
5020
5320
  total: 3,
5021
5321
  message: "Importing leads (phase 1/3 \u2014 preprocess + commit)"
5022
5322
  });
5023
- const importResult = await importLeads.execute(client, {
5323
+ const importResultRaw = await importLeads.execute(client, {
5024
5324
  domains: params.domains,
5025
5325
  records: params.records,
5026
5326
  mappings: params.mappings,
5027
5327
  per_phase_budget_ms: perPhaseBudget,
5028
5328
  total_budget_ms: totalBudget,
5029
- ...params.dry_run === true ? { dry_run: true } : {}
5329
+ ...params.dry_run === true ? { dry_run: true } : {},
5330
+ wait_for_completion: true
5030
5331
  }, ctx);
5332
+ if (isImportLeadsRunningResult(importResultRaw)) {
5333
+ throw client.makeError("IMPORT_ASYNC_UNEXPECTED", "Import returned an async handle while import_and_qualify was waiting for completion", "Retry with wait_for_completion=false and poll leadbay_import_status, or retry the blocking call.", "POST /imports");
5334
+ }
5335
+ const importResult = importResultRaw;
5031
5336
  if (importResult.cancelled) {
5032
5337
  return {
5033
5338
  kind: "result",
@@ -5320,7 +5625,7 @@ import { mkdir as mkdirAsync, lstat, open as fsOpen, readFile, rename, stat, unl
5320
5625
  import { constants as fsConstants } from "fs";
5321
5626
  import { dirname, resolve as resolvePath } from "path";
5322
5627
  import { homedir, platform } from "os";
5323
- import { createHash as createHash2, randomUUID as randomUUID2 } from "crypto";
5628
+ import { createHash as createHash3, randomUUID as randomUUID2 } from "crypto";
5324
5629
  var DEFAULT_IDEMPOTENCY_WINDOW_MS = 5 * 60 * 1e3;
5325
5630
  var TTL_MS = 30 * 24 * 60 * 60 * 1e3;
5326
5631
  var UUIDV4_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
@@ -5335,7 +5640,7 @@ function computeIdempotencyKey(args) {
5335
5640
  args.phone ? "p1" : "p0",
5336
5641
  `l${args.lens_id}`
5337
5642
  ];
5338
- return createHash2("sha256").update(parts.join("|")).digest("hex");
5643
+ return createHash3("sha256").update(parts.join("|")).digest("hex");
5339
5644
  }
5340
5645
  function computeQualifyIdempotencyKey(args) {
5341
5646
  const parts = [
@@ -5345,7 +5650,16 @@ function computeQualifyIdempotencyKey(args) {
5345
5650
  `l${args.lens_id}`,
5346
5651
  args.mapping_fingerprint
5347
5652
  ];
5348
- return createHash2("sha256").update(parts.join("|")).digest("hex");
5653
+ return createHash3("sha256").update(parts.join("|")).digest("hex");
5654
+ }
5655
+ function computeImportIdempotencyKey(args) {
5656
+ const parts = [
5657
+ "import",
5658
+ args.mode,
5659
+ args.dry_run ? "dry1" : "dry0",
5660
+ args.import_fingerprint
5661
+ ];
5662
+ return createHash3("sha256").update(parts.join("|")).digest("hex");
5349
5663
  }
5350
5664
  function normalizeLaunchInputs(args) {
5351
5665
  return {
@@ -5483,7 +5797,7 @@ var LocalBulkStore = class {
5483
5797
  throw new Error("missing launched_at");
5484
5798
  if (!Array.isArray(r.lead_ids) || !r.lead_ids.every((x) => typeof x === "string"))
5485
5799
  throw new Error("invalid lead_ids");
5486
- if (r.status !== "pending" && r.status !== "launched" && r.status !== "failed")
5800
+ if (r.status !== "pending" && r.status !== "launched" && r.status !== "complete" && r.status !== "failed" && r.status !== "cancelled")
5487
5801
  throw new Error("invalid status");
5488
5802
  if (typeof r.idempotency_key !== "string")
5489
5803
  throw new Error("invalid idempotency_key");
@@ -5510,6 +5824,52 @@ var LocalBulkStore = class {
5510
5824
  out.total_budget_ms = r.total_budget_ms;
5511
5825
  return out;
5512
5826
  }
5827
+ if (kind === "import") {
5828
+ if (!Array.isArray(r.import_ids) || !r.import_ids.every((x) => typeof x === "string"))
5829
+ throw new Error("invalid import_ids");
5830
+ if (r.mode !== "domains" && r.mode !== "records")
5831
+ throw new Error("invalid mode");
5832
+ if (typeof r.dry_run !== "boolean")
5833
+ throw new Error("invalid dry_run");
5834
+ if (typeof r.records_total !== "number")
5835
+ throw new Error("invalid records_total");
5836
+ const out = {
5837
+ kind: "import",
5838
+ bulk_id: r.bulk_id,
5839
+ launched_at: r.launched_at,
5840
+ lead_ids: r.lead_ids,
5841
+ import_ids: r.import_ids,
5842
+ mode: r.mode,
5843
+ dry_run: r.dry_run,
5844
+ records_total: r.records_total,
5845
+ status: r.status,
5846
+ idempotency_key: r.idempotency_key,
5847
+ durability: this.backend
5848
+ };
5849
+ if (r.result && typeof r.result === "object") {
5850
+ const result = r.result;
5851
+ if (Array.isArray(result.leads) && Array.isArray(result.not_imported) && Array.isArray(result.importIds) && result.importIds.every((x) => typeof x === "string")) {
5852
+ out.result = {
5853
+ leads: result.leads,
5854
+ not_imported: result.not_imported,
5855
+ importIds: result.importIds
5856
+ };
5857
+ }
5858
+ }
5859
+ if (r.progress && typeof r.progress === "object") {
5860
+ const p = r.progress;
5861
+ if (typeof p.phase === "string" && typeof p.records_processed === "number" && typeof p.records_total === "number") {
5862
+ out.progress = {
5863
+ phase: p.phase,
5864
+ records_processed: p.records_processed,
5865
+ records_total: p.records_total
5866
+ };
5867
+ }
5868
+ }
5869
+ if (typeof r.error === "string")
5870
+ out.error = r.error;
5871
+ return out;
5872
+ }
5513
5873
  if (kind === "enrich") {
5514
5874
  if (!Array.isArray(r.titles) || !r.titles.every((x) => typeof x === "string"))
5515
5875
  throw new Error("invalid titles");
@@ -5673,10 +6033,119 @@ var LocalBulkStore = class {
5673
6033
  return { record, reused: false };
5674
6034
  });
5675
6035
  }
6036
+ async findOrCreatePendingImport(args) {
6037
+ const idempotency_key = computeImportIdempotencyKey({
6038
+ import_fingerprint: args.import_fingerprint,
6039
+ mode: args.mode,
6040
+ dry_run: args.dry_run
6041
+ });
6042
+ const window = args.idempotency_window_ms ?? DEFAULT_IDEMPOTENCY_WINDOW_MS;
6043
+ return this.mutex.run(async () => {
6044
+ const all = this.prune(await this.readAll());
6045
+ const nowMs = this.now();
6046
+ const existing = all.find((r) => r.kind === "import" && r.idempotency_key === idempotency_key && r.status !== "failed" && r.status !== "cancelled" && nowMs - Date.parse(r.launched_at) < window);
6047
+ if (existing) {
6048
+ this.logger?.info?.(`bulk.reused kind=import bulk_id=${existing.bulk_id} seconds_since_original=${Math.round((nowMs - Date.parse(existing.launched_at)) / 1e3)}`);
6049
+ return {
6050
+ record: existing,
6051
+ reused: true,
6052
+ seconds_since_original: Math.round((nowMs - Date.parse(existing.launched_at)) / 1e3)
6053
+ };
6054
+ }
6055
+ const record = {
6056
+ kind: "import",
6057
+ bulk_id: randomUUID2(),
6058
+ launched_at: new Date(nowMs).toISOString(),
6059
+ lead_ids: [],
6060
+ import_ids: [],
6061
+ mode: args.mode,
6062
+ dry_run: args.dry_run,
6063
+ records_total: args.records_total,
6064
+ progress: {
6065
+ phase: "queued",
6066
+ records_processed: 0,
6067
+ records_total: args.records_total
6068
+ },
6069
+ status: "pending",
6070
+ idempotency_key,
6071
+ durability: this.backend
6072
+ };
6073
+ all.push(record);
6074
+ await this.writeAll(all);
6075
+ this.logger?.info?.(`bulk.registered kind=import bulk_id=${record.bulk_id} mode=${record.mode} records_total=${record.records_total} durability=${record.durability}`);
6076
+ return { record, reused: false };
6077
+ });
6078
+ }
5676
6079
  async getQualify(bulk_id) {
5677
6080
  const r = await this.get(bulk_id);
5678
6081
  return r && r.kind === "qualify" ? r : void 0;
5679
6082
  }
6083
+ async getImport(bulk_id) {
6084
+ const r = await this.get(bulk_id);
6085
+ return r && r.kind === "import" ? r : void 0;
6086
+ }
6087
+ async setImportIds(bulk_id, import_ids) {
6088
+ return this.mutex.run(async () => {
6089
+ const all = this.prune(await this.readAll());
6090
+ const idx = all.findIndex((r) => r.bulk_id === bulk_id && r.kind === "import");
6091
+ if (idx < 0)
6092
+ throw new Error(`import bulk_id not found: ${bulk_id}`);
6093
+ const record = all[idx];
6094
+ all[idx] = {
6095
+ ...record,
6096
+ import_ids: [...new Set(import_ids)].sort(),
6097
+ status: record.status === "pending" ? "launched" : record.status
6098
+ };
6099
+ await this.writeAll(all);
6100
+ return all[idx];
6101
+ });
6102
+ }
6103
+ async setImportProgress(bulk_id, progress) {
6104
+ return this.mutex.run(async () => {
6105
+ const all = this.prune(await this.readAll());
6106
+ const idx = all.findIndex((r) => r.bulk_id === bulk_id && r.kind === "import");
6107
+ if (idx < 0)
6108
+ throw new Error(`import bulk_id not found: ${bulk_id}`);
6109
+ const record = all[idx];
6110
+ all[idx] = { ...record, progress };
6111
+ await this.writeAll(all);
6112
+ return all[idx];
6113
+ });
6114
+ }
6115
+ async markImportComplete(bulk_id, result) {
6116
+ return this.mutex.run(async () => {
6117
+ const all = this.prune(await this.readAll());
6118
+ const idx = all.findIndex((r) => r.bulk_id === bulk_id && r.kind === "import");
6119
+ if (idx < 0)
6120
+ throw new Error(`import bulk_id not found: ${bulk_id}`);
6121
+ const record = all[idx];
6122
+ all[idx] = {
6123
+ ...record,
6124
+ import_ids: [...new Set(result.importIds)].sort(),
6125
+ result,
6126
+ progress: {
6127
+ phase: "complete",
6128
+ records_processed: record.records_total,
6129
+ records_total: record.records_total
6130
+ },
6131
+ status: "complete"
6132
+ };
6133
+ await this.writeAll(all);
6134
+ this.logger?.info?.(`bulk.import_complete bulk_id=${bulk_id}`);
6135
+ return all[idx];
6136
+ });
6137
+ }
6138
+ async markImportFailed(bulk_id, error) {
6139
+ return this.mutex.run(async () => {
6140
+ const all = this.prune(await this.readAll());
6141
+ const idx = all.findIndex((r) => r.bulk_id === bulk_id && r.kind === "import");
6142
+ if (idx < 0)
6143
+ return;
6144
+ all[idx] = { ...all[idx], status: "failed", error };
6145
+ await this.writeAll(all);
6146
+ this.logger?.info?.(`bulk.import_failed bulk_id=${bulk_id}`);
6147
+ });
6148
+ }
5680
6149
  async markLaunched(bulk_id) {
5681
6150
  return this.mutex.run(async () => {
5682
6151
  const all = this.prune(await this.readAll());
@@ -5770,6 +6239,200 @@ async function createDefaultBulkStore(opts = {}) {
5770
6239
  }
5771
6240
  }
5772
6241
 
6242
+ // ../core/dist/composite/import-status.js
6243
+ function summarizeImports(imports, dryRun) {
6244
+ let recordsTotal = 0;
6245
+ let recordsProcessed = 0;
6246
+ let hasPreprocess = false;
6247
+ let hasProcess = false;
6248
+ let hasFailed = false;
6249
+ for (const imp of imports) {
6250
+ recordsTotal += Number(imp.total_records ?? 0);
6251
+ recordsProcessed += Number(imp.imported_records ?? 0);
6252
+ if (!imp.pre_processing?.finished) {
6253
+ hasPreprocess = true;
6254
+ continue;
6255
+ }
6256
+ if (imp.pre_processing?.error) {
6257
+ hasFailed = true;
6258
+ continue;
6259
+ }
6260
+ if (dryRun === true) {
6261
+ continue;
6262
+ }
6263
+ if (!imp.processing?.finished) {
6264
+ if (dryRun === false || imp.processing != null)
6265
+ hasProcess = true;
6266
+ continue;
6267
+ }
6268
+ if (imp.processing?.error) {
6269
+ hasFailed = true;
6270
+ }
6271
+ }
6272
+ const phase = hasFailed ? "failed" : hasPreprocess ? "preprocess" : hasProcess ? "process" : imports.length > 0 ? "complete" : "queued";
6273
+ return {
6274
+ phase,
6275
+ records_processed: recordsProcessed,
6276
+ records_total: recordsTotal
6277
+ };
6278
+ }
6279
+ var importStatus = {
6280
+ name: "leadbay_import_status",
6281
+ annotations: {
6282
+ title: "Poll import status",
6283
+ readOnlyHint: true,
6284
+ destructiveHint: false,
6285
+ idempotentHint: true,
6286
+ openWorldHint: true
6287
+ },
6288
+ description: "Retrieve the current state of an async lead import. Pass `handle_id` returned by leadbay_import_leads({wait_for_completion:false}), or pass legacy `importIds[]` to inspect backend wizard rows. This status call performs a single refresh pass and never polls in a loop.\nWhen to use: after leadbay_import_leads or leadbay_import_and_qualify returns `{status:'running', handle_id}` for the import phase, call this tool later to retrieve progress or the final import result without re-running the import.\nWhen NOT to use: for qualification handles returned as `qualify_id` \u2014 use leadbay_qualify_status for those; or when you still want the legacy blocking behavior from leadbay_import_leads with wait_for_completion=true.",
6289
+ inputSchema: {
6290
+ type: "object",
6291
+ properties: {
6292
+ handle_id: {
6293
+ type: "string",
6294
+ description: "UUIDv4 handle returned by leadbay_import_leads when wait_for_completion=false."
6295
+ },
6296
+ importIds: {
6297
+ type: "array",
6298
+ description: "Legacy backend file-import ids to inspect directly.",
6299
+ items: { type: "string" }
6300
+ }
6301
+ },
6302
+ additionalProperties: false
6303
+ },
6304
+ outputSchema: {
6305
+ type: "object",
6306
+ properties: {
6307
+ status: { type: "string", description: "running, complete, or failed." },
6308
+ handle_id: { type: "string" },
6309
+ importIds: { type: "array", items: { type: "string" } },
6310
+ progress: { type: "object" },
6311
+ result: {
6312
+ type: "object",
6313
+ description: "Final import result when the handle has completed in this MCP instance."
6314
+ },
6315
+ error: { type: "string" },
6316
+ region: { type: "string" },
6317
+ _meta: { type: "object" }
6318
+ },
6319
+ required: ["status", "importIds", "progress", "region", "_meta"]
6320
+ },
6321
+ execute: async (client, params, ctx) => {
6322
+ let handleId = params.handle_id;
6323
+ let importIds = params.importIds ?? [];
6324
+ let handleDryRun;
6325
+ if (handleId) {
6326
+ if (!isValidBulkId(handleId)) {
6327
+ throw client.makeError("BULK_INVALID_ID", "handle_id is not a valid UUIDv4", "Pass the handle_id returned by leadbay_import_leads verbatim.", "");
6328
+ }
6329
+ if (!ctx?.bulkTracker) {
6330
+ throw client.makeError("BULK_TRACKER_UNAVAILABLE", "No BulkTracker configured on this MCP instance", "leadbay_import_status needs a BulkTracker to resolve handle_id. Pass importIds[] directly as a fallback.", "");
6331
+ }
6332
+ const record = await ctx.bulkTracker.getImport(handleId);
6333
+ if (!record) {
6334
+ const any = await ctx.bulkTracker.get(handleId);
6335
+ if (any && any.kind !== "import") {
6336
+ throw client.makeError("BULK_WRONG_KIND", "This handle was not created by leadbay_import_leads", "Use leadbay_qualify_status for qualify ids or leadbay_bulk_enrich_status for enrich ids.", "");
6337
+ }
6338
+ throw client.makeError("BULK_NOT_FOUND", "No import record for that handle_id", "It may have expired (30-day TTL) or the MCP process was restarted without persistence.", "");
6339
+ }
6340
+ importIds = record.import_ids;
6341
+ handleDryRun = record.dry_run;
6342
+ if (record.status === "complete" && record.result) {
6343
+ return {
6344
+ status: "complete",
6345
+ handle_id: handleId,
6346
+ importIds,
6347
+ progress: record.progress ?? {
6348
+ phase: "complete",
6349
+ records_processed: record.records_total,
6350
+ records_total: record.records_total
6351
+ },
6352
+ result: record.result,
6353
+ region: client.region,
6354
+ _meta: client.lastMeta ?? {
6355
+ region: client.region,
6356
+ endpoint: "bulk-store",
6357
+ latency_ms: null,
6358
+ retry_after: null
6359
+ }
6360
+ };
6361
+ }
6362
+ if (record.status === "failed") {
6363
+ return {
6364
+ status: "failed",
6365
+ handle_id: handleId,
6366
+ importIds,
6367
+ progress: record.progress ?? {
6368
+ phase: "failed",
6369
+ records_processed: 0,
6370
+ records_total: record.records_total
6371
+ },
6372
+ error: record.error ?? "import failed",
6373
+ region: client.region,
6374
+ _meta: client.lastMeta ?? {
6375
+ region: client.region,
6376
+ endpoint: "bulk-store",
6377
+ latency_ms: null,
6378
+ retry_after: null
6379
+ }
6380
+ };
6381
+ }
6382
+ if (importIds.length === 0) {
6383
+ return {
6384
+ status: "running",
6385
+ handle_id: handleId,
6386
+ importIds,
6387
+ progress: record.progress ?? {
6388
+ phase: "queued",
6389
+ records_processed: 0,
6390
+ records_total: record.records_total
6391
+ },
6392
+ region: client.region,
6393
+ _meta: client.lastMeta ?? {
6394
+ region: client.region,
6395
+ endpoint: "bulk-store",
6396
+ latency_ms: null,
6397
+ retry_after: null
6398
+ }
6399
+ };
6400
+ }
6401
+ }
6402
+ if (importIds.length === 0) {
6403
+ throw client.makeError("IMPORT_STATUS_INPUT_REQUIRED", "Pass either handle_id or importIds[]", "Call leadbay_import_leads with wait_for_completion=false first, then pass its handle_id.", "");
6404
+ }
6405
+ const imports = await Promise.all(importIds.map((id) => client.request("GET", `/imports/${id}`)));
6406
+ const progress = summarizeImports(imports, handleDryRun);
6407
+ const failed = imports.find((i) => i.pre_processing?.error || i.processing?.error);
6408
+ const complete = imports.every((i) => {
6409
+ if (i.pre_processing?.error || i.processing?.error)
6410
+ return false;
6411
+ if (handleDryRun === true)
6412
+ return Boolean(i.pre_processing?.finished);
6413
+ if (handleDryRun === false)
6414
+ return Boolean(i.processing?.finished);
6415
+ return Boolean(i.processing?.finished || i.pre_processing?.finished && !i.processing);
6416
+ });
6417
+ return {
6418
+ status: failed ? "failed" : complete ? "complete" : "running",
6419
+ ...handleId ? { handle_id: handleId } : {},
6420
+ importIds,
6421
+ progress,
6422
+ ...failed ? {
6423
+ error: failed.pre_processing?.error ?? failed.processing?.error ?? "import failed"
6424
+ } : {},
6425
+ region: client.region,
6426
+ _meta: client.lastMeta ?? {
6427
+ region: client.region,
6428
+ endpoint: "GET /imports/<id>",
6429
+ latency_ms: null,
6430
+ retry_after: null
6431
+ }
6432
+ };
6433
+ }
6434
+ };
6435
+
5773
6436
  // ../core/dist/composite/qualify-status.js
5774
6437
  var qualifyStatus = {
5775
6438
  name: "leadbay_qualify_status",
@@ -5865,7 +6528,8 @@ var qualifyStatus = {
5865
6528
  if (!record) {
5866
6529
  const any = await ctx.bulkTracker.get(params.qualify_id);
5867
6530
  if (any && any.kind !== "qualify") {
5868
- throw client.makeError("BULK_WRONG_KIND", "This bulk_id was created by leadbay_enrich_titles, not leadbay_import_and_qualify", "Call leadbay_bulk_enrich_status with this id instead.", "");
6531
+ const hint = any.kind === "import" ? "Call leadbay_import_status with this id instead." : "Call leadbay_bulk_enrich_status with this id instead.";
6532
+ throw client.makeError("BULK_WRONG_KIND", `This bulk_id was created by ${any.kind}, not leadbay_import_and_qualify`, hint, "");
5869
6533
  }
5870
6534
  throw client.makeError("BULK_NOT_FOUND", "No qualify record for that qualify_id", "It may have expired (30-day TTL) or the MCP process was restarted without persistence. Re-launch via leadbay_import_and_qualify.", "");
5871
6535
  }
@@ -5927,7 +6591,7 @@ var qualifyStatus = {
5927
6591
  const out = {
5928
6592
  qualify_id: record.bulk_id,
5929
6593
  launched_at: record.launched_at,
5930
- status: record.status,
6594
+ status: record.status === "complete" ? "launched" : record.status,
5931
6595
  import_ids: record.import_ids,
5932
6596
  lens_id: record.lens_id,
5933
6597
  lead_ids: record.lead_ids,
@@ -6429,12 +7093,12 @@ var bulkEnrichStatus = {
6429
7093
  hint: "The record may have aged out (30-day TTL) or the MCP process was restarted without persistence. Launch a new enrichment via leadbay_enrich_titles."
6430
7094
  };
6431
7095
  }
6432
- if (record.kind === "qualify") {
7096
+ if (record.kind !== "enrich") {
6433
7097
  return {
6434
7098
  error: true,
6435
7099
  code: "BULK_WRONG_KIND",
6436
- message: "This bulk_id was created by leadbay_import_and_qualify, not leadbay_enrich_titles.",
6437
- hint: "Call leadbay_qualify_status with this id instead.",
7100
+ message: `This bulk_id was created by ${record.kind === "qualify" ? "leadbay_import_and_qualify" : "leadbay_import_leads"}, not leadbay_enrich_titles.`,
7101
+ hint: record.kind === "qualify" ? "Call leadbay_qualify_status with this id instead." : "Call leadbay_import_status with this id instead.",
6438
7102
  bulk_id: record.bulk_id
6439
7103
  };
6440
7104
  }
@@ -7438,6 +8102,7 @@ var compositeReadTools = [
7438
8102
  accountStatus,
7439
8103
  bulkEnrichStatus,
7440
8104
  qualifyStatus,
8105
+ importStatus,
7441
8106
  // listMappableFields is granular-shaped but the import composites depend on
7442
8107
  // it for discoverability; expose it always-on so agents can find custom fields
7443
8108
  // without needing LEADBAY_MCP_ADVANCED=1.
@@ -7523,6 +8188,7 @@ export {
7523
8188
  LocalBulkStore,
7524
8189
  InMemoryBulkStore,
7525
8190
  createDefaultBulkStore,
8191
+ importStatus,
7526
8192
  qualifyStatus,
7527
8193
  enrichTitles,
7528
8194
  bulkEnrichStatus,
@@ -47,6 +47,7 @@ import {
47
47
  granularWriteTools,
48
48
  importAndQualify,
49
49
  importLeads,
50
+ importStatus,
50
51
  isValidBulkId,
51
52
  launchBulkEnrichment,
52
53
  listLenses,
@@ -74,7 +75,7 @@ import {
74
75
  tools,
75
76
  updateLens,
76
77
  updateLensFilter
77
- } from "./chunk-NLG7GUZ3.js";
78
+ } from "./chunk-QAOJARMK.js";
78
79
  export {
79
80
  InMemoryBulkStore,
80
81
  LeadbayClient,
@@ -123,6 +124,7 @@ export {
123
124
  granularWriteTools,
124
125
  importAndQualify,
125
126
  importLeads,
127
+ importStatus,
126
128
  isValidBulkId,
127
129
  launchBulkEnrichment,
128
130
  listLenses,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@leadbay/mcp",
3
- "version": "0.6.2",
3
+ "version": "0.6.3",
4
4
  "mcpName": "io.github.leadbay/leadbay-mcp",
5
5
  "description": "Model Context Protocol (MCP) server for Leadbay — AI lead discovery, qualification, and enrichment for Claude Desktop, Cursor, and Claude Code.",
6
6
  "type": "module",