@leadbay/mcp 0.2.1 → 0.2.2

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,17 @@
1
1
  # Changelog — @leadbay/mcp
2
2
 
3
+ ## 0.2.2 — 2026-04-21
4
+
5
+ Bug fix + contract correction + mental-model docs release.
6
+
7
+ - **Fix [product#3504](https://github.com/leadbay/product/issues/3504)**: `npx -y @leadbay/mcp` no longer exits silently on Node 25. The `isEntrypoint` check now resolves both sides through `realpathSync`, so the npx shim symlink path matches the real `dist/bin.js`. Previously `main()` never ran under npx and the MCP host saw a dead connection with no diagnostic.
8
+ - Replaced stale `app.leadbay.ai` URLs in error strings (NOT_AUTHENTICATED, AUTH_INVALID, BILLING_SUSPENDED, PERMISSION_DENIED) and CLI help text with runnable commands (`leadbay-mcp install`, `leadbay-mcp login`) or "contact support". Recovery hints include `--region <us|fr>` because the CLI refuses without it (anti-cross-region credential-leak guard).
9
+ - Renamed misleading `avg_score_0_to_10` field on `pull_leads` / `bulk_qualify_leads` qualification summaries to `avg_qualification_boost`. Per-question AI agent scores are discrete boosts (-10/0/10/20), not 0-10 averages — interface JSDoc now reflects the real contract.
10
+ - `SERVER_INSTRUCTIONS` gains three new paragraphs: "How Leadbay works" (inbox + consumption-based pacing), "Two scoring layers" (basic `score` vs AI-qualified top ~10 with `ai_agent_lead_score`), and "Suggested rhythm" (daily check-in + host-agnostic scheduling hint).
11
+ - `leadbay_pull_leads`, `leadbay_research_lead`, `leadbay_bulk_qualify_leads`, `leadbay_enrich_titles`, and `leadbay_account_status` descriptions updated to reinforce the same model so the agent sees it at both top level and per-tool.
12
+ - New regression test `test/smoke/npx-entrypoint.test.ts` guards the symlink invocation path. New non-regression test asserts the inbox/pace/scoring/daily language stays in `SERVER_INSTRUCTIONS`.
13
+ - Live smoke harness extended with composite-level checks + (optional) thinking-model judge that writes a redacted report to `.context/`.
14
+
3
15
  ## 0.2.1 — 2026-04-21
4
16
 
5
17
  Docs-only release.
package/README.md CHANGED
@@ -6,9 +6,9 @@ LLM HINT — if you're an AI agent reading this README to install the server:
6
6
  - DO NOT tell the user "LEADBAY_REGION is auto-detected, drop it" — the
7
7
  server's region auto-probe sends the bearer token to BOTH regions, which
8
8
  is a credential cross-leak. Always pass --region (us or fr) on install.
9
- - DO NOT tell the user to "get an API token from app.leadbay.ai → Settings
10
- API Tokens" that page does not yet exist. Use `install` (mints a
11
- token) or `login --write-config` (mints a token to file).
9
+ - To mint a token, the user runs `install` (auto-registers with MCP clients)
10
+ or `login --write-config` (mints a token to a file). There is no web UI
11
+ for this do not invent one.
12
12
  -->
13
13
 
14
14
  A Model Context Protocol server that lets Claude Desktop, Cursor, Claude Code, and any other MCP-compatible agent find, research, and prepare outreach on B2B prospects using your Leadbay account.
@@ -31,7 +31,7 @@ Add `--include-write` to also enable the write tools (refine_prompt, report_outr
31
31
 
32
32
  `--region us|fr` is required by default — it pins which Leadbay backend gets your password and avoids a silent cross-region credential leak. If you really don't know your region, opt in with `--allow-region-fallback` (your password will hit BOTH backends if the first 401s).
33
33
 
34
- The token is **session-scoped** (full account access, password-equivalent). Treat it like your password. To rotate, log in again to app.leadbay.ai and re-run `install`.
34
+ The token is **session-scoped** (full account access, password-equivalent). Treat it like your password. To rotate, re-run `npx -y @leadbay/mcp install` minting a fresh token invalidates the prior session.
35
35
 
36
36
  **Don't have a Leadbay account?** [Register here](https://wow.leadbay.ai/?register=true).
37
37
 
@@ -126,9 +126,9 @@ Leadbay connection OK.
126
126
  | Problem | Cause | Fix |
127
127
  |---------|-------|-----|
128
128
  | `LEADBAY_TOKEN environment variable is required` | Token missing from config env | Add `LEADBAY_TOKEN` to the `env` block, restart client |
129
- | `Authentication token expired or invalid` | Token revoked or wrong region | Re-generate token at [app.leadbay.ai/settings/api-tokens](https://app.leadbay.ai/settings/api-tokens); verify `LEADBAY_REGION` |
129
+ | `Authentication token expired or invalid` | Token revoked or wrong region | Re-mint a token: `npx -y @leadbay/mcp install --email <you> --region <us\|fr>`; verify `LEADBAY_REGION` |
130
130
  | `Leadbay doctor: could not reach any Leadbay region` | Wrong region OR network blocked | Run `doctor` with `LEADBAY_REGION=fr` to auto-probe. Check `https://api-us.leadbay.app` reachable. |
131
- | `No enrichment credits remaining` | Out of quota | Buy credits at [app.leadbay.ai](https://app.leadbay.ai) |
131
+ | `No enrichment credits remaining` | Out of quota | Contact Leadbay support to extend quota |
132
132
  | Claude Desktop "loading forever" on first use | `npx` cold-start fetching the package | First run takes ~10s. Prefer `npm install -g @leadbay/mcp` for faster startup. |
133
133
  | Claude Desktop doesn't show Leadbay tools | Server crashed at startup | Check `~/Library/Logs/Claude/mcp*.log` (macOS) or `%APPDATA%\Claude\logs\mcp*.log` (Windows). |
134
134
 
package/dist/bin.js CHANGED
@@ -3,12 +3,15 @@ import {
3
3
  compositeReadTools,
4
4
  compositeWriteTools,
5
5
  createClient,
6
+ createDefaultBulkStore,
6
7
  granularReadTools,
7
8
  granularWriteTools,
8
9
  resolveRegion
9
- } from "./chunk-BGJ6JWIO.js";
10
+ } from "./chunk-FJBO2MY2.js";
10
11
 
11
12
  // src/bin.ts
13
+ import { realpathSync } from "fs";
14
+ import { fileURLToPath } from "url";
12
15
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
13
16
 
14
17
  // src/server.ts
@@ -17,7 +20,7 @@ import {
17
20
  CallToolRequestSchema,
18
21
  ListToolsRequestSchema
19
22
  } from "@modelcontextprotocol/sdk/types.js";
20
- var SERVER_INSTRUCTIONS = "After every email, call, message, or meeting with a lead's contact, you MUST call leadbay_report_outreach with verification={source, ref} (gmail_message_id from the Gmail send, calendar_event_id from a booking, or user_confirmed='<the user's literal confirmation>'). Skipping or fabricating verification poisons the human team's pipeline.\n\nStart with leadbay_account_status to see the user's state, then leadbay_pull_leads to surface fresh leads. Use leadbay_research_lead to dig into one lead deeply (qualification answers, signals, contacts). When the user wants more leads, narrower audience, refined criteria, or contact enrichment, use the matching composite tool (bulk_qualify_leads / adjust_audience / refine_prompt / enrich_titles) \u2014 they hide lens permissions, region routing, polling, and selection state from you.";
23
+ var SERVER_INSTRUCTIONS = "After every email, call, message, or meeting with a lead's contact, you MUST call leadbay_report_outreach with verification={source, ref} (gmail_message_id from the Gmail send, calendar_event_id from a booking, or user_confirmed='<the user's literal confirmation>'). Skipping or fabricating verification poisons the human team's pipeline.\n\nHow Leadbay works (mental model): Leadbay is a sales inbox, not a queryable database. Each day the user logs back in, a fresh batch of leads is delivered. Batch size is paced by how many leads the user has actually acted on recently \u2014 some workflows produce a big stream of smaller prospects, others a narrow stream of bigger ones. Pulling more won't produce more; the user acting on leads (outreach, skips, saves) does.\n\nTwo scoring layers: every lead has a basic `score` (firmographic \u2014 already decent, usually correlates with AI). Roughly the top 10 of each batch are also AI-qualified (targeted web research + qualification questions \u2192 `ai_agent_lead_score`, surfaced as `qualification_summary` on leadbay_pull_leads). Leads past the top ~10 are not worse \u2014 the system is saving resources. Call leadbay_bulk_qualify_leads for deeper qualification or leadbay_enrich_titles for contacts on any lead that looks worth it.\n\nStart with leadbay_account_status to see the user's state, then leadbay_pull_leads to surface fresh leads. Use leadbay_research_lead to dig into one lead deeply (qualification answers, signals, contacts). When the user wants more leads, narrower audience, refined criteria, or contact enrichment, use the matching composite tool (bulk_qualify_leads / adjust_audience / refine_prompt / enrich_titles) \u2014 they hide lens permissions, region routing, polling, and selection state from you.\n\nSuggested rhythm: a healthy agent pattern is a daily check-in \u2014 pull fresh leads, skim the auto-qualified top, deepen 1-3 promising ones, propose outreach to the user, then leadbay_report_outreach on what actually got sent. If your host supports scheduling, offer to set up a daily run.";
21
24
  function formatErrorForLLM(err) {
22
25
  if (err && typeof err === "object" && err.error === true) {
23
26
  const parts = [`${err.message}.`, err.hint];
@@ -85,7 +88,10 @@ function buildServer(client, opts = {}) {
85
88
  }
86
89
  const args = req.params.arguments ?? {};
87
90
  try {
88
- const result = await tool.execute(client, args, { logger: opts.logger });
91
+ const result = await tool.execute(client, args, {
92
+ logger: opts.logger,
93
+ bulkTracker: opts.bulkTracker
94
+ });
89
95
  if (result && typeof result === "object" && result.error === true) {
90
96
  return {
91
97
  content: [
@@ -113,7 +119,7 @@ function buildServer(client, opts = {}) {
113
119
 
114
120
  // src/bin.ts
115
121
  import { createRequire } from "module";
116
- var VERSION = "0.2.1";
122
+ var VERSION = "0.2.2";
117
123
  var HELP = `
118
124
  leadbay-mcp ${VERSION} \u2014 Leadbay Model Context Protocol server
119
125
 
@@ -132,7 +138,7 @@ USAGE
132
138
  leadbay-mcp --help Print this help
133
139
 
134
140
  ENV VARS
135
- LEADBAY_TOKEN (required) Bearer token from https://app.leadbay.ai/settings/api-tokens
141
+ LEADBAY_TOKEN (required) Bearer token (run \`leadbay-mcp install\` to mint one).
136
142
  LEADBAY_REGION (optional) "us" or "fr". Auto-detected from /users/me if unset.
137
143
  LEADBAY_BASE_URL (optional) Override API base URL (for staging/dev).
138
144
  LEADBAY_MCP_ADVANCED (optional) Set to "1" to expose granular API tools alongside
@@ -189,7 +195,7 @@ function parseLogLevel(raw) {
189
195
  }
190
196
  function exitWithTokenError() {
191
197
  process.stderr.write(
192
- "leadbay-mcp: LEADBAY_TOKEN environment variable is required.\n 1. Create a token at https://app.leadbay.ai/settings/api-tokens\n 2. Set it in your MCP client config (e.g. claude_desktop_config.json).\n\nRun `leadbay-mcp --help` for the full config template.\n"
198
+ "leadbay-mcp: LEADBAY_TOKEN environment variable is required.\n 1. Run: npx -y @leadbay/mcp install --email <you> --region <us|fr>\n 2. Set it in your MCP client config (e.g. claude_desktop_config.json).\n\nRun `leadbay-mcp --help` for the full config template.\n"
193
199
  );
194
200
  process.exit(1);
195
201
  }
@@ -326,7 +332,7 @@ async function runLogin(args) {
326
332
  let result;
327
333
  try {
328
334
  if (pinnedRegion && !allowFallback) {
329
- const { REGIONS } = await import("./dist-PIXZN6N4.js");
335
+ const { REGIONS } = await import("./dist-FENQ2I7R.js");
330
336
  const baseUrl = REGIONS[pinnedRegion];
331
337
  const c = createClient({ region: pinnedRegion });
332
338
  const token = await loginAt(baseUrl, email, password);
@@ -669,7 +675,7 @@ leadbay-mcp install \u2014 detected MCP clients on this machine:
669
675
  let region;
670
676
  try {
671
677
  if (pinnedRegion && !allowFallback) {
672
- const { REGIONS } = await import("./dist-PIXZN6N4.js");
678
+ const { REGIONS } = await import("./dist-FENQ2I7R.js");
673
679
  const baseUrl = REGIONS[pinnedRegion];
674
680
  token = await loginAt(baseUrl, email, password);
675
681
  region = pinnedRegion;
@@ -721,7 +727,7 @@ leadbay-mcp install \u2014 detected MCP clients on this machine:
721
727
  The token was written into client config files but never printed to your terminal.
722
728
  Verify with: LEADBAY_TOKEN=$(...) npx -y @leadbay/mcp@0.2 doctor
723
729
  Restart your MCP client(s) to pick up the new server.
724
- If you ever leak the token, log in to app.leadbay.ai again to invalidate the prior session.
730
+ If you ever leak the token, run \`leadbay-mcp login --email <you> --region <us|fr>\` to mint a fresh one (which invalidates the prior session).
725
731
  `
726
732
  );
727
733
  return anyOk ? 0 : 1;
@@ -795,10 +801,16 @@ async function main() {
795
801
  const client = await resolveClientFromEnv(logger);
796
802
  const includeAdvanced = process.env.LEADBAY_MCP_ADVANCED === "1";
797
803
  const includeWrite = process.env.LEADBAY_MCP_WRITE === "1";
798
- const server = buildServer(client, { includeAdvanced, includeWrite, logger });
804
+ const bulkTracker = await createDefaultBulkStore({ logger });
805
+ const server = buildServer(client, {
806
+ includeAdvanced,
807
+ includeWrite,
808
+ logger,
809
+ bulkTracker
810
+ });
799
811
  const transport = new StdioServerTransport();
800
812
  logger.info?.(
801
- `Starting MCP server v${VERSION} (advanced=${includeAdvanced}, write=${includeWrite}, baseUrl=${client.baseUrl})`
813
+ `Starting MCP server v${VERSION} (advanced=${includeAdvanced}, write=${includeWrite}, baseUrl=${client.baseUrl}, bulk_store=${bulkTracker.durability})`
802
814
  );
803
815
  await server.connect(transport);
804
816
  }
@@ -806,8 +818,8 @@ var isEntrypoint = (() => {
806
818
  try {
807
819
  const entry = process.argv[1];
808
820
  if (!entry) return false;
809
- const url = new URL(import.meta.url);
810
- return url.pathname === entry || url.pathname.endsWith(entry);
821
+ const self = fileURLToPath(import.meta.url);
822
+ return realpathSync(self) === realpathSync(entry);
811
823
  } catch {
812
824
  return false;
813
825
  }
@@ -240,7 +240,7 @@ var LeadbayClient = class {
240
240
  return this.mockRequest(method, path, body);
241
241
  }
242
242
  if (!this.token) {
243
- throw this.makeError("NOT_AUTHENTICATED", "Not logged in to Leadbay", "Set LEADBAY_TOKEN env var (obtain token at https://app.leadbay.ai/settings/api-tokens), or use the OpenClaw leadbay_login tool", path);
243
+ throw this.makeError("NOT_AUTHENTICATED", "Not logged in to Leadbay", "Set LEADBAY_TOKEN in your MCP client config, or run: npx -y @leadbay/mcp install --email <you> --region <us|fr>", path);
244
244
  }
245
245
  await this.acquireSemaphore();
246
246
  try {
@@ -275,7 +275,7 @@ var LeadbayClient = class {
275
275
  return;
276
276
  }
277
277
  if (!this.token) {
278
- throw this.makeError("NOT_AUTHENTICATED", "Not logged in to Leadbay", "Set LEADBAY_TOKEN env var (obtain token at https://app.leadbay.ai/settings/api-tokens), or use the OpenClaw leadbay_login tool", path);
278
+ throw this.makeError("NOT_AUTHENTICATED", "Not logged in to Leadbay", "Set LEADBAY_TOKEN in your MCP client config, or run: npx -y @leadbay/mcp install --email <you> --region <us|fr>", path);
279
279
  }
280
280
  await this.acquireSemaphore();
281
281
  try {
@@ -332,7 +332,7 @@ var LeadbayClient = class {
332
332
  }
333
333
  const retryAfter = parseRetryAfter(headers["retry-after"]);
334
334
  if (status === 401) {
335
- return this.makeError("AUTH_EXPIRED", "Authentication token expired or invalid", "Your LEADBAY_TOKEN is no longer valid. Regenerate at https://app.leadbay.ai/settings/api-tokens and restart.", endpoint);
335
+ return this.makeError("AUTH_EXPIRED", "Authentication token expired or invalid", "Your LEADBAY_TOKEN is no longer valid. Regenerate it: npx -y @leadbay/mcp login --email <you> --region <us|fr>, then restart your MCP client.", endpoint);
336
336
  }
337
337
  if (status === 429 || status === 402 || parsed?.error === "quota_exceeded" || parsed?.error?.code === "quota_exceeded") {
338
338
  return this.makeError("QUOTA_EXCEEDED", retryAfter ? `Quota exceeded \u2014 retry in ${retryAfter}s` : "Quota exceeded", retryAfter ? `Wait ${retryAfter}s before retrying. Check leadbay_get_quota to see which resource window was hit.` : "Wait, then retry. Check leadbay_get_quota to see which resource window (daily/weekly/monthly) was hit.", endpoint, retryAfter);
@@ -340,9 +340,9 @@ var LeadbayClient = class {
340
340
  if (status === 403) {
341
341
  const msg = parsed?.message || parsed?.error || parsed?.error?.message || "";
342
342
  if (typeof msg === "string" && (msg.includes("suspend") || msg.includes("billing"))) {
343
- return this.makeError("BILLING_SUSPENDED", "Account billing is suspended", "Your Leadbay account billing is suspended. Update at https://app.leadbay.ai", endpoint);
343
+ return this.makeError("BILLING_SUSPENDED", "Account billing is suspended", "Your Leadbay account billing is suspended. Contact Leadbay support.", endpoint);
344
344
  }
345
- return this.makeError("FORBIDDEN", "Insufficient permissions", "Your token does not have access to this resource. Check account permissions at https://app.leadbay.ai", endpoint);
345
+ return this.makeError("FORBIDDEN", "Insufficient permissions", "Your token does not have access to this resource. Contact Leadbay support to verify account permissions.", endpoint);
346
346
  }
347
347
  if (status === 404) {
348
348
  return this.makeError("NOT_FOUND", parsed?.message || parsed?.error?.message || "Resource not found", "Verify the ID is correct", endpoint);
@@ -754,7 +754,7 @@ var getTasteProfile = {
754
754
  question: q.question
755
755
  })),
756
756
  ...isEmpty ? {
757
- hint: "No taste profile configured yet. Set it up at app.leadbay.ai for better lead matching."
757
+ hint: "No taste profile configured yet. Use leadbay_refine_prompt or contact Leadbay support to set one up for better lead matching."
758
758
  } : {}
759
759
  };
760
760
  }
@@ -827,7 +827,7 @@ var enrichContacts = {
827
827
  const me = await client.request("GET", "/users/me");
828
828
  creditsRemaining = me.organization.billing?.ai_credits ?? null;
829
829
  if (creditsRemaining !== null && creditsRemaining <= 0) {
830
- throw client.makeError("QUOTA_EXCEEDED", "No enrichment credits remaining", "Purchase more credits at app.leadbay.ai");
830
+ throw client.makeError("QUOTA_EXCEEDED", "No enrichment credits remaining", "Contact Leadbay support to extend your credit quota");
831
831
  }
832
832
  } catch (e) {
833
833
  if (e?.code === "QUOTA_EXCEEDED")
@@ -1664,11 +1664,11 @@ function summarise(responses) {
1664
1664
  if (excerpt && excerpt.length > 200) {
1665
1665
  excerpt = excerpt.slice(0, 197) + "...";
1666
1666
  }
1667
- return { answered, total, avg_score_0_to_10: avg, best_response_excerpt: excerpt };
1667
+ return { answered, total, avg_qualification_boost: avg, best_response_excerpt: excerpt };
1668
1668
  }
1669
1669
  var pullLeads = {
1670
1670
  name: "leadbay_pull_leads",
1671
- description: "Pull up new leads from the user's last-active lens (the canonical 'show me prospects to work on' tool). Each returned lead carries a one-line qualification_summary built from leadbay_ai_agent_responses, plus the rich tags / scores / recommended_contact_title / engagement counters / in-flight flags from the lead summary. When to use: as the agent's default opening move when the user wants to see leads. When NOT to use: when the user has named a specific lens \u2014 pass lensId to override the auto-resolution. Replaces the older leadbay_find_prospects (which is removed in v0.2.0).",
1671
+ description: "Pull up new leads from the user's last-active lens \u2014 the canonical 'show me today's prospects' tool. Leadbay works like an inbox: each time the user logs back in, a fresh batch is delivered, paced by how many leads they've actually acted on recently. Pulling more won't produce more; user outreach/skips/saves does. Each returned lead carries a one-line qualification_summary built from leadbay_ai_agent_responses, plus the rich tags / scores / recommended_contact_title / engagement counters / in-flight flags from the lead summary. Roughly the top 10 of the batch come pre-qualified (populated qualification_summary + ai_agent_lead_score); leads below the top ~10 carry only the basic firmographic `score` \u2014 not worse, just resource-saved by the system. Call leadbay_bulk_qualify_leads to deepen any of them on demand. When to use: as the agent's default opening move when the user wants to see leads, or as a daily check-in for what's new today. When NOT to use: when the user has named a specific lens \u2014 pass lensId to override the auto-resolution. Replaces the older leadbay_find_prospects (which is removed in v0.2.0).",
1672
1672
  inputSchema: {
1673
1673
  type: "object",
1674
1674
  properties: {
@@ -1701,7 +1701,7 @@ var pullLeads = {
1701
1701
  summary: {
1702
1702
  answered: 0,
1703
1703
  total: 0,
1704
- avg_score_0_to_10: null,
1704
+ avg_qualification_boost: null,
1705
1705
  best_response_excerpt: null
1706
1706
  }
1707
1707
  };
@@ -1783,7 +1783,7 @@ function reshapeWebFetchContent(content) {
1783
1783
  }
1784
1784
  var researchLead = {
1785
1785
  name: "leadbay_research_lead",
1786
- description: "Tell me everything decision-relevant about a single lead. Bundles the lens-scoped lead profile, the AI qualification answers (the agent's knowledge-base food), the structured web-research signals (with hot flags + sources), the enriched contacts, and the recent notes/epilogue/prospecting activity in one call. Order is deliberate: qualification first, then signals, then firmographics, then contacts, then engagement. When to use: when picking up a single lead from leadbay_pull_leads to decide whether to act on it. When NOT to use: across many leads at once \u2014 that's leadbay_pull_leads' job. (This composite supersedes the lower-level leadbay_get_lead_profile in agent flow; the granular tool stays available for fine-grained access.)",
1786
+ description: "Tell me everything decision-relevant about a single lead. Bundles the lens-scoped lead profile, the AI qualification answers (the agent's knowledge-base food), the structured web-research signals (with hot flags + sources), the enriched contacts, and the recent notes/epilogue/prospecting activity in one call. Order is deliberate: qualification first, then signals, then firmographics, then contacts, then engagement. Scoring has two layers: the basic `score` (firmographic, always present, already decent) and the AI qualification layer (`ai_agent_lead_score` + per-question answers + web_fetch signals). The AI layer is pre-populated for roughly the top 10 of each daily batch, and on-demand (via leadbay_bulk_qualify_leads) for anything below that. Combine both layers when judging a lead. When to use: when picking up a single lead from leadbay_pull_leads to decide whether to act on it. When NOT to use: across many leads at once \u2014 that's leadbay_pull_leads' job. (This composite supersedes the lower-level leadbay_get_lead_profile in agent flow; the granular tool stays available for fine-grained access.)",
1787
1787
  inputSchema: {
1788
1788
  type: "object",
1789
1789
  properties: {
@@ -1997,7 +1997,7 @@ var recallOrderedTitles = {
1997
1997
  // ../core/dist/composite/account-status.js
1998
1998
  var accountStatus = {
1999
1999
  name: "leadbay_account_status",
2000
- description: "Show the user's account state \u2014 admin rights, language, last-active lens, current quota usage across daily/weekly/monthly windows for llm_completion / ai_rescore / web_fetch resources, and whether the org's intelligence is mid-regeneration. When to use: at the start of a session to know what the agent can/can't do, or after a 429 to explain to the user which resource window was exhausted and when it resets. When NOT to use: as a pre-flight gate before bulk ops \u2014 operations themselves return 429; this tool is for context, not gating.",
2000
+ description: "Show the user's account state \u2014 admin rights, language, last-active lens, current quota usage across daily/weekly/monthly windows for llm_completion / ai_rescore / web_fetch resources, and whether the org's intelligence is mid-regeneration. Quota windows also hint at the user's consumption pace: heavy recent activity (ai_rescore / web_fetch near their window limits) is a signal that Leadbay will deliver a larger fresh batch next time the user logs back in, since batch size is paced by real consumption. When to use: at the start of a session to know what the agent can/can't do, or after a 429 to explain to the user which resource window was exhausted and when it resets. When NOT to use: as a pre-flight gate before bulk ops \u2014 operations themselves return 429; this tool is for context, not gating.",
2001
2001
  inputSchema: { type: "object", properties: {} },
2002
2002
  execute: async (client, _params, ctx) => {
2003
2003
  const me = await client.resolveMe();
@@ -2042,7 +2042,7 @@ var DEFAULT_PER_LEAD_BUDGET_MS = 9e4;
2042
2042
  var DEFAULT_TOTAL_BUDGET_MS = 5 * 6e4;
2043
2043
  var bulkQualifyLeads = {
2044
2044
  name: "leadbay_bulk_qualify_leads",
2045
- 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. When to use: when the user wants more qualified leads than what's currently shown. When NOT to use: to qualify a single specific lead \u2014 that's leadbay_qualify_lead (granular, advanced).",
2045
+ 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).",
2046
2046
  inputSchema: {
2047
2047
  type: "object",
2048
2048
  properties: {
@@ -2169,7 +2169,7 @@ var bulkQualifyLeads = {
2169
2169
  qualification_summary: responses.length > 0 ? {
2170
2170
  answered: responses.filter((r) => r.score != null).length,
2171
2171
  total: responses.length,
2172
- avg_score_0_to_10: avg
2172
+ avg_qualification_boost: avg
2173
2173
  } : null,
2174
2174
  signals_count: lastWf?.content ? Object.values(lastWf.content).reduce((acc, arr) => acc + (Array.isArray(arr) ? arr.length : 0), 0) : null,
2175
2175
  _stillRunning: stillRunning
@@ -2194,7 +2194,7 @@ var bulkQualifyLeads = {
2194
2194
  var DEFAULT_CANDIDATE_COUNT = 25;
2195
2195
  var enrichTitles = {
2196
2196
  name: "leadbay_enrich_titles",
2197
- description: "Order contact enrichments by job title across many leads. Two modes: (A) NO titles param \u2014 returns the available titles + Leadbay's title_suggestions + auto_included_titles + a count of enrichable contacts, so the agent can ask the user which titles to enrich. (B) titles given \u2014 calls preview, then launches if there's anything enrichable. On 429 returns {status:'quota_exceeded'} cleanly. Selection lifecycle is wrapped in a try/finally so the user's selection is left clean even on error. When to use: as the agent's go-to enrichment entry point. When NOT to use: to enrich a single contact \u2014 that's leadbay_enrich_contacts (granular).",
2197
+ description: "Order contact enrichments by job title across many leads. Contacts are NOT returned by default with a lead (Leadbay keeps enrichment out-of-band to control cost); the agent requests them on demand via this tool when it's ready to actually reach out. Two modes: (A) NO titles param \u2014 returns the available titles + Leadbay's title_suggestions + auto_included_titles + a count of enrichable contacts, so the agent can ask the user which titles to enrich. (B) titles given \u2014 calls preview, then launches if there's anything enrichable. On 429 returns {status:'quota_exceeded'} cleanly. Selection lifecycle is wrapped in a try/finally so the user's selection is left clean even on error. When to use: as the agent's go-to enrichment entry point, immediately before proposing outreach. When NOT to use: to enrich a single contact \u2014 that's leadbay_enrich_contacts (granular). Speculatively, before the user has committed to outreaching \u2014 enrichment spends credits.",
2198
2198
  inputSchema: {
2199
2199
  type: "object",
2200
2200
  properties: {
@@ -2235,9 +2235,11 @@ var enrichTitles = {
2235
2235
  hint: "Set email:true (most common) or phone:true"
2236
2236
  };
2237
2237
  }
2238
+ const explicitLeadIds = params.leadIds && params.leadIds.length > 0;
2239
+ const selectionSource = explicitLeadIds ? "explicit" : "wishlist";
2240
+ const lensId = params.lensId ?? await client.resolveDefaultLens();
2238
2241
  let leadIds = params.leadIds;
2239
2242
  if (!leadIds || leadIds.length === 0) {
2240
- const lensId = params.lensId ?? await client.resolveDefaultLens();
2241
2243
  const cnt = params.candidateCount ?? DEFAULT_CANDIDATE_COUNT;
2242
2244
  const wish = await client.request("GET", `/lenses/${lensId}/leads/wishlist?count=${Math.min(cnt, 50)}&page=0`);
2243
2245
  leadIds = wish.items.map((l) => l.id);
@@ -2311,9 +2313,53 @@ var enrichTitles = {
2311
2313
  would_launch: { titles: params.titles, email, phone }
2312
2314
  };
2313
2315
  }
2316
+ const tracker = ctx?.bulkTracker;
2317
+ let bulkRecord;
2318
+ let bulkReused = false;
2319
+ let bulkSecondsSinceOriginal;
2320
+ if (tracker) {
2321
+ const res = await tracker.findOrCreatePending({
2322
+ lead_ids: leadIds,
2323
+ titles: params.titles,
2324
+ email,
2325
+ phone,
2326
+ lens_id: lensId,
2327
+ selection_source: selectionSource
2328
+ });
2329
+ bulkRecord = {
2330
+ bulk_id: res.record.bulk_id,
2331
+ launched_at: res.record.launched_at,
2332
+ durability: res.record.durability
2333
+ };
2334
+ bulkReused = res.reused;
2335
+ bulkSecondsSinceOriginal = res.seconds_since_original;
2336
+ if (bulkReused && res.record.status !== "failed") {
2337
+ return {
2338
+ mode: "already_launched",
2339
+ re_used: true,
2340
+ bulk_id: res.record.bulk_id,
2341
+ launched_at: res.record.launched_at,
2342
+ durability: res.record.durability,
2343
+ seconds_since_original_launch: bulkSecondsSinceOriginal ?? 0,
2344
+ titles: params.titles,
2345
+ email,
2346
+ phone,
2347
+ preview,
2348
+ message: `No new enrichment was ordered; quota not spent. An identical bulk was launched ${bulkSecondsSinceOriginal ?? 0}s ago. Poll leadbay_bulk_enrich_status with this bulk_id for results.`,
2349
+ next_action: "Call leadbay_bulk_enrich_status({bulk_id}) to check progress; include_contacts=true for the final read."
2350
+ };
2351
+ }
2352
+ }
2314
2353
  try {
2315
2354
  await client.requestVoid("POST", "/leads/selection/enrichment/launch", { titles: params.titles, email, phone });
2316
2355
  } catch (err) {
2356
+ if (bulkRecord && tracker) {
2357
+ try {
2358
+ await tracker.markFailed(bulkRecord.bulk_id);
2359
+ } catch (e) {
2360
+ ctx?.logger?.warn?.(`enrich_titles: tracker.markFailed failed: ${e?.message ?? e}`);
2361
+ }
2362
+ }
2317
2363
  if (err?.code === "QUOTA_EXCEEDED") {
2318
2364
  return {
2319
2365
  status: "quota_exceeded",
@@ -2324,6 +2370,26 @@ var enrichTitles = {
2324
2370
  }
2325
2371
  throw err;
2326
2372
  }
2373
+ if (bulkRecord && tracker) {
2374
+ try {
2375
+ await tracker.markLaunched(bulkRecord.bulk_id);
2376
+ } catch (e) {
2377
+ ctx?.logger?.warn?.(`enrich_titles: tracker.markLaunched failed: ${e?.message ?? e}`);
2378
+ return {
2379
+ mode: "launched_tracker_pending",
2380
+ launched: true,
2381
+ preview,
2382
+ bulk_id: bulkRecord.bulk_id,
2383
+ launched_at: bulkRecord.launched_at,
2384
+ durability: bulkRecord.durability,
2385
+ titles: params.titles,
2386
+ email,
2387
+ phone,
2388
+ message: "Enrichment job launched on the backend, but the local tracker record could not be flipped to 'launched'. The bulk_id is still valid \u2014 leadbay_bulk_enrich_status will return status:'pending' until the tracker heals.",
2389
+ next_action: "Wait ~60s, then call leadbay_bulk_enrich_status({bulk_id}). If it persists, restart the MCP."
2390
+ };
2391
+ }
2392
+ }
2327
2393
  return {
2328
2394
  mode: "launched",
2329
2395
  preview,
@@ -2331,8 +2397,11 @@ var enrichTitles = {
2331
2397
  titles: params.titles,
2332
2398
  email,
2333
2399
  phone,
2334
- message: "Enrichment job launched. The Leadbay backend does not return a bulk_id (probed 2026-04-20) \u2014 track results by polling individual leads via leadbay_get_contacts after ~60s; contact.enrichment.done flips to true.",
2335
- next_action: "Wait ~60s, then call leadbay_research_lead or leadbay_get_contacts on the leads you care about."
2400
+ bulk_id: bulkRecord?.bulk_id,
2401
+ launched_at: bulkRecord?.launched_at,
2402
+ durability: bulkRecord?.durability,
2403
+ message: bulkRecord ? "Enrichment job launched. Backend has no server-side bulk_id yet; MCP minted a client-side bulk_id (persisted to disk by default) so you can poll via leadbay_bulk_enrich_status." : "Enrichment job launched. No bulk_id tracker configured \u2014 poll leadbay_get_contacts per lead after ~60s; contact.enrichment.done flips to true.",
2404
+ next_action: bulkRecord ? "Call leadbay_bulk_enrich_status({bulk_id}) after ~60s; pass include_contacts=true for the final read." : "Wait ~60s, then call leadbay_research_lead or leadbay_get_contacts on the leads you care about."
2336
2405
  };
2337
2406
  } finally {
2338
2407
  try {
@@ -2347,6 +2416,523 @@ var enrichTitles = {
2347
2416
  }
2348
2417
  };
2349
2418
 
2419
+ // ../core/dist/jobs/bulk-store.js
2420
+ import { mkdir as mkdirAsync, lstat, open as fsOpen, readFile, rename, stat, unlink } from "fs/promises";
2421
+ import { constants as fsConstants } from "fs";
2422
+ import { dirname, resolve as resolvePath } from "path";
2423
+ import { homedir, platform } from "os";
2424
+ import { createHash, randomUUID } from "crypto";
2425
+ var DEFAULT_IDEMPOTENCY_WINDOW_MS = 5 * 60 * 1e3;
2426
+ var TTL_MS = 30 * 24 * 60 * 60 * 1e3;
2427
+ 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;
2428
+ function isValidBulkId(v) {
2429
+ return typeof v === "string" && UUIDV4_RE.test(v);
2430
+ }
2431
+ function computeIdempotencyKey(args) {
2432
+ const parts = [
2433
+ [...args.lead_ids].sort().join(","),
2434
+ [...args.titles].sort().join(","),
2435
+ args.email ? "e1" : "e0",
2436
+ args.phone ? "p1" : "p0",
2437
+ `l${args.lens_id}`
2438
+ ];
2439
+ return createHash("sha256").update(parts.join("|")).digest("hex");
2440
+ }
2441
+ function normalizeLaunchInputs(args) {
2442
+ return {
2443
+ lead_ids: [...new Set(args.lead_ids)].sort(),
2444
+ titles: [...new Set(args.titles)].sort()
2445
+ };
2446
+ }
2447
+ var AsyncMutex = class {
2448
+ locked = false;
2449
+ queue = [];
2450
+ async lock() {
2451
+ if (!this.locked) {
2452
+ this.locked = true;
2453
+ return;
2454
+ }
2455
+ return new Promise((resolve) => {
2456
+ this.queue.push(() => {
2457
+ this.locked = true;
2458
+ resolve();
2459
+ });
2460
+ });
2461
+ }
2462
+ unlock() {
2463
+ this.locked = false;
2464
+ const next = this.queue.shift();
2465
+ if (next)
2466
+ next();
2467
+ }
2468
+ async run(fn) {
2469
+ await this.lock();
2470
+ try {
2471
+ return await fn();
2472
+ } finally {
2473
+ this.unlock();
2474
+ }
2475
+ }
2476
+ };
2477
+ var LocalBulkStore = class {
2478
+ backend;
2479
+ path;
2480
+ logger;
2481
+ allowUnsafePath;
2482
+ now;
2483
+ mutex = new AsyncMutex();
2484
+ memory = [];
2485
+ // Cached file resolution — computed lazily on first access.
2486
+ initialized = false;
2487
+ constructor(opts) {
2488
+ this.backend = opts.backend;
2489
+ this.logger = opts.logger;
2490
+ this.allowUnsafePath = !!opts.allowUnsafePath;
2491
+ this.now = opts.now ?? Date.now;
2492
+ if (this.backend === "file") {
2493
+ if (!opts.path) {
2494
+ throw new Error("LocalBulkStore: path is required when backend=file");
2495
+ }
2496
+ this.path = resolvePath(opts.path);
2497
+ this.validatePath(this.path);
2498
+ }
2499
+ }
2500
+ get durability() {
2501
+ return this.backend;
2502
+ }
2503
+ // Exposed for tests and ops tooling.
2504
+ get resolvedPath() {
2505
+ return this.path;
2506
+ }
2507
+ validatePath(p) {
2508
+ if (this.allowUnsafePath)
2509
+ return;
2510
+ const home = resolvePath(homedir());
2511
+ if (p !== home && !p.startsWith(home + "/") && !p.startsWith(home + "\\")) {
2512
+ throw new Error(`LocalBulkStore: path ${p} is outside $HOME (${home}). Set LEADBAY_BULK_STORE_PATH_UNSAFE=1 to override.`);
2513
+ }
2514
+ }
2515
+ async ensureInitialized() {
2516
+ if (this.initialized || this.backend !== "file") {
2517
+ this.initialized = true;
2518
+ return;
2519
+ }
2520
+ const dir = dirname(this.path);
2521
+ await mkdirAsync(dir, { recursive: true, mode: 448 });
2522
+ try {
2523
+ const st = await lstat(this.path);
2524
+ if (st.isSymbolicLink()) {
2525
+ throw new Error(`LocalBulkStore: refusing to use ${this.path} \u2014 path is a symlink. Set LEADBAY_BULK_STORE_PATH_UNSAFE=1 to override.`);
2526
+ }
2527
+ } catch (err) {
2528
+ if (err?.code !== "ENOENT")
2529
+ throw err;
2530
+ }
2531
+ this.initialized = true;
2532
+ }
2533
+ // ─── Storage layer (file or memory) ──────────────────────────────────────
2534
+ async readAll() {
2535
+ if (this.backend === "memory")
2536
+ return [...this.memory];
2537
+ await this.ensureInitialized();
2538
+ let raw;
2539
+ try {
2540
+ raw = await readFile(this.path, "utf8");
2541
+ } catch (err) {
2542
+ if (err?.code === "ENOENT")
2543
+ return [];
2544
+ throw err;
2545
+ }
2546
+ let parsed;
2547
+ try {
2548
+ parsed = JSON.parse(raw);
2549
+ } catch (err) {
2550
+ this.logger?.warn?.(`bulk.record_dropped file_parse_failed ${err?.message ?? err}`);
2551
+ return [];
2552
+ }
2553
+ if (!Array.isArray(parsed)) {
2554
+ this.logger?.warn?.("bulk.record_dropped file_not_array");
2555
+ return [];
2556
+ }
2557
+ const out = [];
2558
+ for (const entry of parsed) {
2559
+ try {
2560
+ out.push(this.validateRecord(entry));
2561
+ } catch (err) {
2562
+ this.logger?.warn?.(`bulk.record_dropped invalid_record ${err?.message ?? err}`);
2563
+ }
2564
+ }
2565
+ return out;
2566
+ }
2567
+ validateRecord(raw) {
2568
+ if (!raw || typeof raw !== "object")
2569
+ throw new Error("not an object");
2570
+ const r = raw;
2571
+ if (!isValidBulkId(r.bulk_id))
2572
+ throw new Error("invalid bulk_id");
2573
+ if (typeof r.launched_at !== "string")
2574
+ throw new Error("missing launched_at");
2575
+ if (!Array.isArray(r.lead_ids) || !r.lead_ids.every((x) => typeof x === "string"))
2576
+ throw new Error("invalid lead_ids");
2577
+ if (!Array.isArray(r.titles) || !r.titles.every((x) => typeof x === "string"))
2578
+ throw new Error("invalid titles");
2579
+ if (typeof r.email !== "boolean")
2580
+ throw new Error("invalid email");
2581
+ if (typeof r.phone !== "boolean")
2582
+ throw new Error("invalid phone");
2583
+ if (typeof r.lens_id !== "number")
2584
+ throw new Error("invalid lens_id");
2585
+ if (r.selection_source !== "explicit" && r.selection_source !== "wishlist")
2586
+ throw new Error("invalid selection_source");
2587
+ if (r.status !== "pending" && r.status !== "launched" && r.status !== "failed")
2588
+ throw new Error("invalid status");
2589
+ if (typeof r.idempotency_key !== "string")
2590
+ throw new Error("invalid idempotency_key");
2591
+ return {
2592
+ bulk_id: r.bulk_id,
2593
+ launched_at: r.launched_at,
2594
+ lead_ids: r.lead_ids,
2595
+ titles: r.titles,
2596
+ email: r.email,
2597
+ phone: r.phone,
2598
+ lens_id: r.lens_id,
2599
+ selection_source: r.selection_source,
2600
+ status: r.status,
2601
+ idempotency_key: r.idempotency_key,
2602
+ durability: this.backend
2603
+ };
2604
+ }
2605
+ async writeAll(records) {
2606
+ if (this.backend === "memory") {
2607
+ this.memory = records.map((r) => ({ ...r, durability: "memory" }));
2608
+ return;
2609
+ }
2610
+ await this.ensureInitialized();
2611
+ const payload = records.map((r) => ({ ...r, durability: "file" }));
2612
+ const json = JSON.stringify(payload, null, 2);
2613
+ const tmp = this.path + ".tmp";
2614
+ let fh = await openTmpFileExclusive(tmp);
2615
+ try {
2616
+ await fh.writeFile(json, { encoding: "utf8" });
2617
+ await fh.sync();
2618
+ } finally {
2619
+ await fh.close();
2620
+ }
2621
+ if (platform() === "win32") {
2622
+ try {
2623
+ await unlink(this.path);
2624
+ } catch (err) {
2625
+ if (err?.code !== "ENOENT")
2626
+ throw err;
2627
+ }
2628
+ }
2629
+ await rename(tmp, this.path);
2630
+ try {
2631
+ const dirFh = await fsOpen(dirname(this.path), "r");
2632
+ try {
2633
+ await dirFh.sync();
2634
+ } finally {
2635
+ await dirFh.close();
2636
+ }
2637
+ } catch {
2638
+ }
2639
+ }
2640
+ // ─── TTL cleanup ─────────────────────────────────────────────────────────
2641
+ prune(records) {
2642
+ const cutoff = this.now() - TTL_MS;
2643
+ const kept = [];
2644
+ for (const r of records) {
2645
+ const launched = Date.parse(r.launched_at);
2646
+ if (Number.isFinite(launched) && launched >= cutoff) {
2647
+ kept.push(r);
2648
+ } else {
2649
+ this.logger?.info?.(`bulk.ttl_dropped bulk_id=${r.bulk_id} launched_at=${r.launched_at}`);
2650
+ }
2651
+ }
2652
+ return kept;
2653
+ }
2654
+ // ─── BulkTracker API ────────────────────────────────────────────────────
2655
+ async findOrCreatePending(args) {
2656
+ const { lead_ids, titles } = normalizeLaunchInputs(args);
2657
+ const idempotency_key = computeIdempotencyKey({
2658
+ lead_ids,
2659
+ titles,
2660
+ email: args.email,
2661
+ phone: args.phone,
2662
+ lens_id: args.lens_id
2663
+ });
2664
+ const window = args.idempotency_window_ms ?? DEFAULT_IDEMPOTENCY_WINDOW_MS;
2665
+ return this.mutex.run(async () => {
2666
+ const all = this.prune(await this.readAll());
2667
+ const nowMs = this.now();
2668
+ const existing = all.find((r) => r.idempotency_key === idempotency_key && r.status !== "failed" && nowMs - Date.parse(r.launched_at) < window);
2669
+ if (existing) {
2670
+ this.logger?.info?.(`bulk.reused bulk_id=${existing.bulk_id} seconds_since_original=${Math.round((nowMs - Date.parse(existing.launched_at)) / 1e3)}`);
2671
+ return {
2672
+ record: existing,
2673
+ reused: true,
2674
+ seconds_since_original: Math.round((nowMs - Date.parse(existing.launched_at)) / 1e3)
2675
+ };
2676
+ }
2677
+ const record = {
2678
+ bulk_id: randomUUID(),
2679
+ launched_at: new Date(nowMs).toISOString(),
2680
+ lead_ids,
2681
+ titles,
2682
+ email: args.email,
2683
+ phone: args.phone,
2684
+ lens_id: args.lens_id,
2685
+ selection_source: args.selection_source,
2686
+ status: "pending",
2687
+ idempotency_key,
2688
+ durability: this.backend
2689
+ };
2690
+ all.push(record);
2691
+ await this.writeAll(all);
2692
+ this.logger?.info?.(`bulk.registered bulk_id=${record.bulk_id} lens_id=${record.lens_id} lead_count=${record.lead_ids.length} titles_count=${record.titles.length} durability=${record.durability}`);
2693
+ return { record, reused: false };
2694
+ });
2695
+ }
2696
+ async markLaunched(bulk_id) {
2697
+ return this.mutex.run(async () => {
2698
+ const all = this.prune(await this.readAll());
2699
+ const idx = all.findIndex((r) => r.bulk_id === bulk_id);
2700
+ if (idx < 0) {
2701
+ throw new Error(`bulk_id not found: ${bulk_id}`);
2702
+ }
2703
+ all[idx] = { ...all[idx], status: "launched" };
2704
+ await this.writeAll(all);
2705
+ this.logger?.info?.(`bulk.launched bulk_id=${bulk_id}`);
2706
+ return all[idx];
2707
+ });
2708
+ }
2709
+ async markFailed(bulk_id) {
2710
+ return this.mutex.run(async () => {
2711
+ const all = this.prune(await this.readAll());
2712
+ const idx = all.findIndex((r) => r.bulk_id === bulk_id);
2713
+ if (idx < 0) {
2714
+ return;
2715
+ }
2716
+ all[idx] = { ...all[idx], status: "failed" };
2717
+ await this.writeAll(all);
2718
+ this.logger?.info?.(`bulk.launch_failed bulk_id=${bulk_id}`);
2719
+ });
2720
+ }
2721
+ async get(bulk_id) {
2722
+ return this.mutex.run(async () => {
2723
+ const all = this.prune(await this.readAll());
2724
+ return all.find((r) => r.bulk_id === bulk_id);
2725
+ });
2726
+ }
2727
+ async list() {
2728
+ return this.mutex.run(async () => {
2729
+ const all = this.prune(await this.readAll());
2730
+ return [...all].sort((a, b) => Date.parse(b.launched_at) - Date.parse(a.launched_at));
2731
+ });
2732
+ }
2733
+ };
2734
+ async function openTmpFileExclusive(path) {
2735
+ try {
2736
+ return await fsOpen(path, fsConstants.O_CREAT | fsConstants.O_WRONLY | fsConstants.O_EXCL, 384);
2737
+ } catch (err) {
2738
+ if (err?.code === "EEXIST") {
2739
+ await unlink(path).catch(() => {
2740
+ });
2741
+ return fsOpen(path, fsConstants.O_CREAT | fsConstants.O_WRONLY | fsConstants.O_EXCL, 384);
2742
+ }
2743
+ throw err;
2744
+ }
2745
+ }
2746
+ var InMemoryBulkStore = class extends LocalBulkStore {
2747
+ constructor(opts = {}) {
2748
+ super({ backend: "memory", logger: opts.logger, now: opts.now });
2749
+ }
2750
+ };
2751
+ async function createDefaultBulkStore(opts = {}) {
2752
+ const env = opts.env ?? process.env;
2753
+ const allowMemory = env.LEADBAY_BULK_STORE_ALLOW_MEMORY === "1";
2754
+ const allowUnsafePath = env.LEADBAY_BULK_STORE_PATH_UNSAFE === "1";
2755
+ const path = env.LEADBAY_BULK_STORE_PATH ?? resolvePath(homedir(), ".leadbay", "bulks.json");
2756
+ try {
2757
+ const store = new LocalBulkStore({
2758
+ backend: "file",
2759
+ path,
2760
+ logger: opts.logger,
2761
+ allowUnsafePath
2762
+ });
2763
+ await store.ensureInitialized();
2764
+ await stat(dirname(path));
2765
+ return store;
2766
+ } catch (err) {
2767
+ if (!allowMemory) {
2768
+ const msg = `bulk store init failed at ${path}: ${err?.message ?? err}. Set LEADBAY_BULK_STORE_ALLOW_MEMORY=1 to fall back to in-memory (handles won't survive MCP restart), or set LEADBAY_BULK_STORE_PATH to a writable path.`;
2769
+ opts.logger?.error?.(msg);
2770
+ throw new Error(msg);
2771
+ }
2772
+ opts.logger?.warn?.(`bulk.fallback_memory path=${path} reason=${err?.message ?? err}`);
2773
+ return new LocalBulkStore({ backend: "memory", logger: opts.logger });
2774
+ }
2775
+ }
2776
+
2777
+ // ../core/dist/composite/bulk-enrich-status.js
2778
+ var STATUS_FETCH_CONCURRENCY = 5;
2779
+ async function pMap(items, fn, concurrency) {
2780
+ const out = new Array(items.length);
2781
+ let next = 0;
2782
+ async function worker() {
2783
+ while (true) {
2784
+ const i = next++;
2785
+ if (i >= items.length)
2786
+ return;
2787
+ out[i] = await fn(items[i], i);
2788
+ }
2789
+ }
2790
+ await Promise.all(Array.from({ length: Math.min(concurrency, items.length) }, () => worker()));
2791
+ return out;
2792
+ }
2793
+ var bulkEnrichStatus = {
2794
+ name: "leadbay_bulk_enrich_status",
2795
+ description: "Check status + per-lead contacts for a bulk enrichment you previously launched via leadbay_enrich_titles. Returns the bulk_id, progress per lead (done/total enrichable contacts), and overall progress. When include_contacts=true (opt-in), includes each contact's email/phone/job_title/enrichment.done. When to use: poll this after leadbay_enrich_titles returns a bulk_id. Default include_contacts=false for cheap status polls; set include_contacts=true once all_done flips for the final read. When NOT to use: as a substitute for leadbay_research_lead \u2014 that already includes enriched contacts for a single lead.",
2796
+ inputSchema: {
2797
+ type: "object",
2798
+ properties: {
2799
+ bulk_id: {
2800
+ type: "string",
2801
+ description: "UUIDv4 returned by leadbay_enrich_titles at launch time. Required."
2802
+ },
2803
+ include_contacts: {
2804
+ type: "boolean",
2805
+ description: "If true, return the full contact list per lead (email, phone, enrichment.done). Default false \u2014 cheap status polls."
2806
+ }
2807
+ },
2808
+ required: ["bulk_id"]
2809
+ },
2810
+ execute: async (client, params, ctx) => {
2811
+ if (!isValidBulkId(params.bulk_id)) {
2812
+ return {
2813
+ error: true,
2814
+ code: "BULK_INVALID_ID",
2815
+ message: "bulk_id is not a valid UUIDv4",
2816
+ hint: "Pass the bulk_id returned by leadbay_enrich_titles verbatim."
2817
+ };
2818
+ }
2819
+ if (!ctx?.bulkTracker) {
2820
+ return {
2821
+ error: true,
2822
+ code: "BULK_TRACKER_UNAVAILABLE",
2823
+ message: "No BulkTracker configured on this MCP instance",
2824
+ hint: "This composite requires a BulkTracker in ToolContext. Upgrade to @leadbay/mcp \u22650.3.0 or run with LEADBAY_BULK_STORE_ALLOW_MEMORY=1."
2825
+ };
2826
+ }
2827
+ const includeContacts = params.include_contacts ?? false;
2828
+ const startMs = Date.now();
2829
+ let record;
2830
+ try {
2831
+ record = await ctx.bulkTracker.get(params.bulk_id);
2832
+ } catch (err) {
2833
+ return {
2834
+ error: true,
2835
+ code: "BULK_STORE_UNAVAILABLE",
2836
+ message: `Bulk store read failed: ${err?.message ?? err}`,
2837
+ hint: "Check the file at $LEADBAY_BULK_STORE_PATH (default ~/.leadbay/bulks.json). Set LEADBAY_BULK_STORE_ALLOW_MEMORY=1 to fall back to in-memory storage on startup (handles won't survive restart)."
2838
+ };
2839
+ }
2840
+ if (!record) {
2841
+ return {
2842
+ error: true,
2843
+ code: "BULK_NOT_FOUND",
2844
+ message: "No bulk record for that bulk_id",
2845
+ 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."
2846
+ };
2847
+ }
2848
+ if (record.status === "pending") {
2849
+ return {
2850
+ error: true,
2851
+ code: "BULK_PENDING",
2852
+ message: "Bulk is in 'pending' state \u2014 the launch is in flight or the MCP crashed between launch and ack.",
2853
+ hint: "Retry leadbay_bulk_enrich_status in a few seconds. If it persists >60s, relaunch via leadbay_enrich_titles.",
2854
+ bulk_id: record.bulk_id,
2855
+ launched_at: record.launched_at
2856
+ };
2857
+ }
2858
+ if (record.status === "failed") {
2859
+ return {
2860
+ error: true,
2861
+ code: "BULK_LAUNCH_FAILED",
2862
+ message: "The original /enrichment/launch POST failed; no backend enrichment was ordered.",
2863
+ hint: "Call leadbay_enrich_titles again \u2014 the failed record won't block a fresh launch.",
2864
+ bulk_id: record.bulk_id,
2865
+ launched_at: record.launched_at
2866
+ };
2867
+ }
2868
+ const results = await pMap(record.lead_ids, async (leadId) => {
2869
+ try {
2870
+ const out = await getContacts.execute(client, { leadId });
2871
+ const contacts = Array.isArray(out?.contacts) ? out.contacts : [];
2872
+ const enrichable = contacts.filter((c) => c && c.enrichment);
2873
+ const done = enrichable.filter((c) => c.enrichment?.done === true).length;
2874
+ const total = enrichable.length;
2875
+ return {
2876
+ kind: "ok",
2877
+ lead_id: leadId,
2878
+ done,
2879
+ total,
2880
+ contacts: includeContacts ? contacts : void 0
2881
+ };
2882
+ } catch (err) {
2883
+ return {
2884
+ kind: "fail",
2885
+ lead_id: leadId,
2886
+ code: err?.code ?? "UNKNOWN",
2887
+ retry_after: err?._meta?.retry_after
2888
+ };
2889
+ }
2890
+ }, STATUS_FETCH_CONCURRENCY);
2891
+ const leads = [];
2892
+ const partialFailures = [];
2893
+ let totalDone = 0;
2894
+ let totalAll = 0;
2895
+ for (const r of results) {
2896
+ if (r.kind === "fail") {
2897
+ partialFailures.push({
2898
+ lead_id: r.lead_id,
2899
+ code: r.code,
2900
+ ...r.retry_after !== void 0 ? { retry_after: r.retry_after } : {}
2901
+ });
2902
+ continue;
2903
+ }
2904
+ leads.push({
2905
+ lead_id: r.lead_id,
2906
+ ...r.contacts ? { contacts: r.contacts } : {},
2907
+ enrichment_progress: { done: r.done, total: r.total }
2908
+ });
2909
+ totalDone += r.done;
2910
+ totalAll += r.total;
2911
+ }
2912
+ const overallProgress = {
2913
+ done: totalDone,
2914
+ total: totalAll,
2915
+ done_ratio: totalAll === 0 ? 0 : totalDone / totalAll
2916
+ };
2917
+ const allDone = totalAll > 0 && totalDone === totalAll && partialFailures.length === 0;
2918
+ ctx?.logger?.info?.(`bulk.status_checked bulk_id=${record.bulk_id} done=${totalDone} total=${totalAll} wall_ms=${Date.now() - startMs}`);
2919
+ return {
2920
+ bulk_id: record.bulk_id,
2921
+ launched_at: record.launched_at,
2922
+ status: record.status,
2923
+ durability: record.durability,
2924
+ titles: record.titles,
2925
+ email: record.email,
2926
+ phone: record.phone,
2927
+ lens_id: record.lens_id,
2928
+ leads,
2929
+ overall_progress: overallProgress,
2930
+ all_done: allDone,
2931
+ ...partialFailures.length > 0 ? { partial_failures: partialFailures } : {}
2932
+ };
2933
+ }
2934
+ };
2935
+
2350
2936
  // ../core/dist/composite/adjust-audience.js
2351
2937
  function tokens(s) {
2352
2938
  return s.toLowerCase().split(/[^\p{L}\p{N}]+/u).filter(Boolean);
@@ -2924,6 +3510,7 @@ var compositeReadTools = [
2924
3510
  researchLead,
2925
3511
  recallOrderedTitles,
2926
3512
  accountStatus,
3513
+ bulkEnrichStatus,
2927
3514
  // Keep the existing composites available too.
2928
3515
  researchCompany,
2929
3516
  prepareOutreach
@@ -2996,6 +3583,11 @@ export {
2996
3583
  accountStatus,
2997
3584
  bulkQualifyLeads,
2998
3585
  enrichTitles,
3586
+ isValidBulkId,
3587
+ LocalBulkStore,
3588
+ InMemoryBulkStore,
3589
+ createDefaultBulkStore,
3590
+ bulkEnrichStatus,
2999
3591
  adjustAudience,
3000
3592
  refinePrompt,
3001
3593
  answerClarification,
@@ -1,11 +1,14 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
+ InMemoryBulkStore,
3
4
  LeadbayClient,
5
+ LocalBulkStore,
4
6
  REGIONS,
5
7
  accountStatus,
6
8
  addNote,
7
9
  adjustAudience,
8
10
  answerClarification,
11
+ bulkEnrichStatus,
9
12
  bulkQualifyLeads,
10
13
  clearMockJournal,
11
14
  clearSelection,
@@ -14,6 +17,7 @@ import {
14
17
  compositeTools,
15
18
  compositeWriteTools,
16
19
  createClient,
20
+ createDefaultBulkStore,
17
21
  createLens,
18
22
  createLensDraft,
19
23
  deselectLeads,
@@ -40,6 +44,7 @@ import {
40
44
  granularReadTools,
41
45
  granularTools,
42
46
  granularWriteTools,
47
+ isValidBulkId,
43
48
  launchBulkEnrichment,
44
49
  listLenses,
45
50
  listSectors,
@@ -64,14 +69,17 @@ import {
64
69
  tools,
65
70
  updateLens,
66
71
  updateLensFilter
67
- } from "./chunk-BGJ6JWIO.js";
72
+ } from "./chunk-FJBO2MY2.js";
68
73
  export {
74
+ InMemoryBulkStore,
69
75
  LeadbayClient,
76
+ LocalBulkStore,
70
77
  REGIONS,
71
78
  accountStatus,
72
79
  addNote,
73
80
  adjustAudience,
74
81
  answerClarification,
82
+ bulkEnrichStatus,
75
83
  bulkQualifyLeads,
76
84
  clearMockJournal,
77
85
  clearSelection,
@@ -80,6 +88,7 @@ export {
80
88
  compositeTools,
81
89
  compositeWriteTools,
82
90
  createClient,
91
+ createDefaultBulkStore,
83
92
  createLens,
84
93
  createLensDraft,
85
94
  deselectLeads,
@@ -106,6 +115,7 @@ export {
106
115
  granularReadTools,
107
116
  granularTools,
108
117
  granularWriteTools,
118
+ isValidBulkId,
109
119
  launchBulkEnrichment,
110
120
  listLenses,
111
121
  listSectors,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@leadbay/mcp",
3
- "version": "0.2.1",
3
+ "version": "0.2.2",
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": {
@@ -27,6 +27,7 @@
27
27
  "@modelcontextprotocol/sdk": "1.29.0"
28
28
  },
29
29
  "devDependencies": {
30
+ "@anthropic-ai/sdk": "^0.40.0",
30
31
  "@leadbay/core": "workspace:*"
31
32
  },
32
33
  "engines": {