@leadbay/mcp 0.9.1 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/bin.js CHANGED
@@ -8,7 +8,7 @@ import {
8
8
  granularReadTools,
9
9
  granularWriteTools,
10
10
  resolveRegion
11
- } from "./chunk-ZRTAIXEJ.js";
11
+ } from "./chunk-F3EWCHME.js";
12
12
 
13
13
  // src/bin.ts
14
14
  import { realpathSync } from "fs";
@@ -37,7 +37,7 @@ Run the Leadbay daily check-in for me. Treat this prompt the same way for any eq
37
37
 
38
38
  # Resilience rules for Leadbay long-running tools
39
39
 
40
- These four rules apply to every Leadbay workflow that calls \`leadbay_pull_leads\`, \`leadbay_bulk_qualify_leads\`, \`leadbay_research_lead\`, \`leadbay_import_and_qualify\`, or \`leadbay_enrich_titles\`. **Treat timeouts and stream-closed errors as transient, not as signals to replan.**
40
+ These four rules apply to every Leadbay workflow that calls \`leadbay_pull_leads\`, \`leadbay_bulk_qualify_leads\`, \`leadbay_research_lead_by_id\`, \`leadbay_import_and_qualify\`, or \`leadbay_enrich_titles\`. **Treat timeouts and stream-closed errors as transient, not as signals to replan.**
41
41
 
42
42
  ## Rule 1 \u2014 Pin the lens
43
43
 
@@ -47,9 +47,9 @@ After your first \`leadbay_pull_leads\` call, capture \`response.lens.id\` into
47
47
 
48
48
  \`leadbay_bulk_qualify_leads\` and \`leadbay_import_and_qualify\` accept \`wait_for_completion:false\`, which returns \`{status:'running', qualify_id}\` immediately. Then poll \`leadbay_qualify_status\` (or \`leadbay_import_status\`) every ~10s until the job completes. **Use the async pattern by default** \u2014 the blocking default can exceed the MCP client's per-call timeout on large batches and produce a misleading \`"Request timed out"\` even though the server is still working.
49
49
 
50
- ## Rule 3 \u2014 Serialize \`leadbay_research_lead\` fan-out
50
+ ## Rule 3 \u2014 Serialize \`leadbay_research_lead_by_id\` fan-out
51
51
 
52
- \`leadbay_research_lead\` is composite and reads many sub-resources. Calling it on 10 leads in parallel can saturate the transport and produce \`"Tool permission stream closed"\` errors that look like permission failures but are really backpressure. **Call it sequentially**, or at most 3 in parallel. If one call fails with a stream/timeout error, retry that one call once before moving on; on a second failure, note the lead and continue \u2014 do not abandon the remaining leads.
52
+ \`leadbay_research_lead_by_id\` is composite and reads many sub-resources. Calling it on 10 leads in parallel can saturate the transport and produce \`"Tool permission stream closed"\` errors that look like permission failures but are really backpressure. **Call it sequentially**, or at most 3 in parallel. If one call fails with a stream/timeout error, retry that one call once before moving on; on a second failure, note the lead and continue \u2014 do not abandon the remaining leads.
53
53
 
54
54
  ## Rule 4 \u2014 Retry, don't replan
55
55
 
@@ -122,7 +122,7 @@ If \`qualification_summary.answered == 0\` or \`avg_qualification_boost\` is nul
122
122
  **Column 2 \u2014 Why it fits**
123
123
 
124
124
  - One sentence, \u2264 20 words.
125
- - Synthesize from (in priority order, whichever is present) the lead's \`short_description\`, top 2 \`tags[].display_name\`, and the gist of \`qualification_summary.best_response_excerpt\`. The trim payload does NOT carry the longer \`description\` field \u2014 for that, agent must call \`leadbay_research_lead\` or \`leadbay_research_company\`.
125
+ - Synthesize from (in priority order, whichever is present) the lead's \`short_description\`, top 2 \`tags[].display_name\`, and the gist of \`qualification_summary.best_response_excerpt\`. The trim payload does NOT carry the longer \`description\` field \u2014 for that, agent must call \`leadbay_research_lead_by_id\` or \`leadbay_research_lead_by_name_fuzzy\`.
126
126
  - Do NOT append \`(boost N)\` \u2014 the \u2756 cap in column 1 already carries that signal.
127
127
  - No bullet lists, no line breaks inside the cell.
128
128
 
@@ -164,7 +164,7 @@ If the batch returns fewer than 10 qualified leads, top it up: call \`leadbay_bu
164
164
 
165
165
  # PHASE 4 \u2014 DEEP DIVE (every promising lead)
166
166
 
167
- Call \`leadbay_research_lead\` on **every** lead from your top 10 that the user might realistically prospect today (filter out clearly weak fits if any). Don't pick just one. **Call it sequentially** \u2014 one at a time, or batches of at most 3 in parallel. Do not fire 10 in parallel \u2014 it triggers transport backpressure that surfaces as \`"Tool permission stream closed"\` errors (see Rule 3 above). If a call fails, retry that single lead once; if the retry also fails, note the lead id and continue. Report Phase 4 results even if 1\u20132 leads were unresearchable.
167
+ Call \`leadbay_research_lead_by_id\` on **every** lead from your top 10 that the user might realistically prospect today (filter out clearly weak fits if any). Don't pick just one. **Call it sequentially** \u2014 one at a time, or batches of at most 3 in parallel. Do not fire 10 in parallel \u2014 it triggers transport backpressure that surfaces as \`"Tool permission stream closed"\` errors (see Rule 3 above). If a call fails, retry that single lead once; if the retry also fails, note the lead id and continue. Report Phase 4 results even if 1\u20132 leads were unresearchable.
168
168
 
169
169
  For each researched lead surface:
170
170
  - what makes it promising (1\u20132 sentences citing signals from the research)
@@ -372,7 +372,7 @@ You are working with Leadbay through the \`leadbay_*\` MCP tools. This prompt or
372
372
 
373
373
  # Resilience rules for Leadbay long-running tools
374
374
 
375
- These four rules apply to every Leadbay workflow that calls \`leadbay_pull_leads\`, \`leadbay_bulk_qualify_leads\`, \`leadbay_research_lead\`, \`leadbay_import_and_qualify\`, or \`leadbay_enrich_titles\`. **Treat timeouts and stream-closed errors as transient, not as signals to replan.**
375
+ These four rules apply to every Leadbay workflow that calls \`leadbay_pull_leads\`, \`leadbay_bulk_qualify_leads\`, \`leadbay_research_lead_by_id\`, \`leadbay_import_and_qualify\`, or \`leadbay_enrich_titles\`. **Treat timeouts and stream-closed errors as transient, not as signals to replan.**
376
376
 
377
377
  ## Rule 1 \u2014 Pin the lens
378
378
 
@@ -382,9 +382,9 @@ After your first \`leadbay_pull_leads\` call, capture \`response.lens.id\` into
382
382
 
383
383
  \`leadbay_bulk_qualify_leads\` and \`leadbay_import_and_qualify\` accept \`wait_for_completion:false\`, which returns \`{status:'running', qualify_id}\` immediately. Then poll \`leadbay_qualify_status\` (or \`leadbay_import_status\`) every ~10s until the job completes. **Use the async pattern by default** \u2014 the blocking default can exceed the MCP client's per-call timeout on large batches and produce a misleading \`"Request timed out"\` even though the server is still working.
384
384
 
385
- ## Rule 3 \u2014 Serialize \`leadbay_research_lead\` fan-out
385
+ ## Rule 3 \u2014 Serialize \`leadbay_research_lead_by_id\` fan-out
386
386
 
387
- \`leadbay_research_lead\` is composite and reads many sub-resources. Calling it on 10 leads in parallel can saturate the transport and produce \`"Tool permission stream closed"\` errors that look like permission failures but are really backpressure. **Call it sequentially**, or at most 3 in parallel. If one call fails with a stream/timeout error, retry that one call once before moving on; on a second failure, note the lead and continue \u2014 do not abandon the remaining leads.
387
+ \`leadbay_research_lead_by_id\` is composite and reads many sub-resources. Calling it on 10 leads in parallel can saturate the transport and produce \`"Tool permission stream closed"\` errors that look like permission failures but are really backpressure. **Call it sequentially**, or at most 3 in parallel. If one call fails with a stream/timeout error, retry that one call once before moving on; on a second failure, note the lead and continue \u2014 do not abandon the remaining leads.
388
388
 
389
389
  ## Rule 4 \u2014 Retry, don't replan
390
390
 
@@ -412,8 +412,8 @@ leadbay_pull_leads leadbay_pull_followups
412
412
  leadbay_daily_check_in leadbay_followup_check_in
413
413
  \u2502 \u2502
414
414
  \u2502 optional: \u2502 filters by user phrasing:
415
- \u2502 research_company / \u2502 geo, sector, recency,
416
- \u2502 research_lead \u2502 liked / pushback / outcome
415
+ \u2502 research_lead_by_name_fuzzy / \u2502 geo, sector, recency,
416
+ \u2502 research_lead_by_id \u2502 liked / pushback / outcome
417
417
  \u2502 (deepen profile) \u2502
418
418
  \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u252C\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518
419
419
  \u25BC
@@ -579,7 +579,7 @@ If \`qualification_summary.answered == 0\` or \`avg_qualification_boost\` is nul
579
579
  **Column 2 \u2014 Why it fits**
580
580
 
581
581
  - One sentence, \u2264 20 words.
582
- - Synthesize from (in priority order, whichever is present) the lead's \`short_description\`, top 2 \`tags[].display_name\`, and the gist of \`qualification_summary.best_response_excerpt\`. The trim payload does NOT carry the longer \`description\` field \u2014 for that, agent must call \`leadbay_research_lead\` or \`leadbay_research_company\`.
582
+ - Synthesize from (in priority order, whichever is present) the lead's \`short_description\`, top 2 \`tags[].display_name\`, and the gist of \`qualification_summary.best_response_excerpt\`. The trim payload does NOT carry the longer \`description\` field \u2014 for that, agent must call \`leadbay_research_lead_by_id\` or \`leadbay_research_lead_by_name_fuzzy\`.
583
583
  - Do NOT append \`(boost N)\` \u2014 the \u2756 cap in column 1 already carries that signal.
584
584
  - No bullet lists, no line breaks inside the cell.
585
585
 
@@ -618,7 +618,7 @@ When the response carries \`social_urls\` (the post-fix multi-platform URL block
618
618
  ABOVE the table, add a 1\u20132 sentence "Standouts from this batch" line that calls out the 3 highest-\`ai_agent_lead_score\` rows \u2014 this is supplementary commentary, not a replacement for the table.
619
619
 
620
620
  # PHASE 4 \u2014 RECOMMEND
621
- Recommend the single most-promising lead from this batch and offer to research it deeply with \`leadbay_research_lead\`. Do not actually call \`research_lead\` yet \u2014 wait for my go.
621
+ Recommend the single most-promising lead from this batch and offer to research it deeply with \`leadbay_research_lead_by_id\`. Do not actually call \`research_lead_by_id\` yet \u2014 wait for my go.
622
622
  `;
623
623
  var leadbay_refine_audience = `
624
624
  Refine the Leadbay audience prompt to: {{arg:instruction}}
@@ -661,17 +661,17 @@ The response carries either a completed result or an async handle. Render a brie
661
661
 
662
662
  **When the user's request implied a downstream use** ("import then prep outreach for them"), emit \`Imported leadIds: <up to 5 ids, then '+N more'>\` \u2014 just the ids. Let the next composite render the leads.
663
663
 
664
- Defer the full list of imported leads to \`leadbay_pull_leads\` or \`leadbay_research_lead\` in NEXT STEPS.
664
+ Defer the full list of imported leads to \`leadbay_pull_leads\` or \`leadbay_research_lead_by_id\` in NEXT STEPS.
665
665
 
666
666
 
667
667
  # PHASE 2 \u2014 DEEP DIVE
668
- When the import resolves, call \`leadbay_research_lead\` on the new leadId. Render the result using the canonical single-record card layout \u2014 detect MODE A (Discovery) since the user asked to "research" a domain rather than to prepare outreach:
668
+ When the import resolves, call \`leadbay_research_lead_by_id\` on the new leadId. Render the result using the canonical single-record card layout \u2014 detect MODE A (Discovery) since the user asked to "research" a domain rather than to prepare outreach:
669
669
 
670
670
  ## RENDERING \u2014 single-record research card, mode-adaptive
671
671
 
672
672
  Present as a single-record card, not a table. This tool gets invoked in two distinct user contexts \u2014 detect which and adapt the body density accordingly.
673
673
 
674
- **MODE A \u2014 Discovery.** The user is evaluating whether to pursue this company as a target. Signals: "tell me about", "what do they do", "is this a fit", "research [company]", arrival via a click-through from \`leadbay_pull_leads\`, no prior outreach context in the conversation. Next step is usually qualify, deep-dive via \`leadbay_research_lead\`, or decide whether to start outreach.
674
+ **MODE A \u2014 Discovery.** The user is evaluating whether to pursue this company as a target. Signals: "tell me about", "what do they do", "is this a fit", "research [company]", arrival via a click-through from \`leadbay_pull_leads\`, no prior outreach context in the conversation. Next step is usually qualify, deep-dive via \`leadbay_research_lead_by_id\`, or decide whether to start outreach.
675
675
 
676
676
  **MODE B \u2014 Contact preparation.** The user is about to call or email someone at this company and needs the talking points. Signals: "I'm calling them", "draft an email", "before my call", "outreach prep", "what should I say", or the conversation has already touched on a specific contact. Next step is usually \`leadbay_prepare_outreach\`.
677
677
 
@@ -758,7 +758,7 @@ on "leadbay leads", "best NEW leads", "what's new today", "show me the
758
758
  day's batch", "let's prospect". Do NOT trigger on follow-up phrasings
759
759
  ("what should I follow up on", "before my trip") \u2014 those go to
760
760
  \`leadbay_followup_check_in\`.
761
- `, "arguments": [], "expected_calls": ["leadbay_account_status", "leadbay_pull_leads", "leadbay_research_lead", "leadbay_bulk_qualify_leads", "leadbay_enrich_contacts"], "failure_modes": ["Calls leadbay_report_outreach without explicit user authorization", "Surfaces fewer than 10 leads when more are available, or fails to top up via leadbay_qualify_top_n when the batch is short", `Replaces the canonical pull_leads table layout with prose per row (the per-tool RENDERING block is the structural contract; "Today's nudges" goes above it, not in place of it)`, "Skips the nudge paragraph entirely \u2014 the table alone is fine but adding the nudge is the value-add", "Skips deep research on promising leads (Phase 4) \u2014 the agent must call leadbay_research_lead on each, not just one", "Triggers contact enrichment without asking the user first (it consumes quota)", "Skips the STOP byproduct and proposes next actions on its own", 'Fires 10 parallel leadbay_research_lead calls and treats "stream closed" errors as terminal \u2014 must serialize and retry singletons', "Re-pulls leadbay_pull_leads without passing the captured lensId, allowing a backend lens shift to discard the Phase 2 batch", 'Treats a "Request timed out" from leadbay_bulk_qualify_leads as terminal instead of retrying with wait_for_completion:false + qualify_status polling', 'Triggers on a follow-up query (e.g., "leads I should follow up with") that should have routed to `leadbay_followup_check_in` \u2014 the two entry points are different data sources (Discover wishlist vs Monitor view) per \xA71.6'] },
761
+ `, "arguments": [], "expected_calls": ["leadbay_account_status", "leadbay_pull_leads", "leadbay_research_lead_by_id", "leadbay_bulk_qualify_leads", "leadbay_enrich_contacts"], "failure_modes": ["Calls leadbay_report_outreach without explicit user authorization", "Surfaces fewer than 10 leads when more are available, or fails to top up via leadbay_qualify_top_n when the batch is short", `Replaces the canonical pull_leads table layout with prose per row (the per-tool RENDERING block is the structural contract; "Today's nudges" goes above it, not in place of it)`, "Skips the nudge paragraph entirely \u2014 the table alone is fine but adding the nudge is the value-add", "Skips deep research on promising leads (Phase 4) \u2014 the agent must call leadbay_research_lead_by_id on each, not just one", "Triggers contact enrichment without asking the user first (it consumes quota)", "Skips the STOP byproduct and proposes next actions on its own", 'Fires 10 parallel leadbay_research_lead_by_id calls and treats "stream closed" errors as terminal \u2014 must serialize and retry singletons', "Re-pulls leadbay_pull_leads without passing the captured lensId, allowing a backend lens shift to discard the Phase 2 batch", 'Treats a "Request timed out" from leadbay_bulk_qualify_leads as terminal instead of retrying with wait_for_completion:false + qualify_status polling', 'Triggers on a follow-up query (e.g., "leads I should follow up with") that should have routed to `leadbay_followup_check_in` \u2014 the two entry points are different data sources (Discover wishlist vs Monitor view) per \xA71.6'] },
762
762
  leadbay_followup_check_in: { "name": "leadbay_followup_check_in", "short_description": `Run the canonical follow-up check-in: surface KNOWN leads from the
763
763
  Monitor view that need re-engagement today, ranked by AI urgency,
764
764
  with the canonical pull_followups table layout. Trigger when the
@@ -766,19 +766,19 @@ user asks "follow up", "already known leads", "leads I haven't
766
766
  contacted", "leads in [city]", "before my trip", "this week",
767
767
  "this month", "what's overdue", "who should I re-engage", or
768
768
  anything that implies pre-existing pipeline context.
769
- `, "arguments": [], "expected_calls": ["leadbay_pull_followups", "leadbay_research_lead", "leadbay_prepare_outreach"], "failure_modes": ["Calls leadbay_pull_leads (the Discover entry point) instead of leadbay_pull_followups \u2014 these are different data sources; the Discover queue does NOT contain Monitor's known-but-cold pipeline", 'Iterates pages of leadbay_pull_leads filtering by engagement_count to "fake" a follow-up view (a real bug observed in 0.9.0 \u2014 the right move is to call pull_followups directly)', "Replaces the canonical pull_followups table layout with prose per row (the per-tool RENDERING block is the structural contract; commentary belongs above or below)", 'Skips the cross-mode pivot offer at the end ("Want to see NEW leads from your wishlist instead?" routes to leadbay_pull_leads)'] },
769
+ `, "arguments": [], "expected_calls": ["leadbay_pull_followups", "leadbay_research_lead_by_id", "leadbay_prepare_outreach"], "failure_modes": ["Calls leadbay_pull_leads (the Discover entry point) instead of leadbay_pull_followups \u2014 these are different data sources; the Discover queue does NOT contain Monitor's known-but-cold pipeline", 'Iterates pages of leadbay_pull_leads filtering by engagement_count to "fake" a follow-up view (a real bug observed in 0.9.0 \u2014 the right move is to call pull_followups directly)', "Replaces the canonical pull_followups table layout with prose per row (the per-tool RENDERING block is the structural contract; commentary belongs above or below)", 'Skips the cross-mode pivot offer at the end ("Want to see NEW leads from your wishlist instead?" routes to leadbay_pull_leads)'] },
770
770
  leadbay_import_file: { "name": "leadbay_import_file", "short_description": "Import a user-supplied CSV/file into Leadbay through five phases with\nevidence gates \u2014 scan, derive, resolve identities, preserve & commit,\nthen optionally qualify and report. The job is to maximize how many\nrows the Leadbay system actually ingests and matches.\n", "arguments": [{ "name": "file", "description": "Path or user-visible name of the CSV/file to import. If omitted, use the file the user attached or referenced.", "required": false }, { "name": "instruction", "description": 'Additional user goal, e.g. "then qualify the leads", "preserve owner phone as a custom field", or "only import restaurants in Manhattan".', "required": false }], "expected_calls": ["leadbay_resolve_import_rows", "leadbay_list_mappable_fields", "leadbay_create_custom_field", "leadbay_import_leads", "leadbay_import_and_qualify", "leadbay_add_note", "leadbay_import_status"], "failure_modes": ["Picks LEADBAY_ID from score alone, name-only, fuzzy-name-only, root-domain-only, brand-only, postcode-only, or city-only evidence", "Drops meaningful business notes or CRM record links instead of preserving them as custom fields or lead notes", "Treats a consumer mailbox domain (gmail.com, hotmail.com, ...) as the company domain", "Skips deriving company_domain from a business email when no website column exists (this kills match rate)", "Skips the COLUMN PRESERVATION PLAN byproduct before importing", "Skips the DECISION LOG byproduct before writing LEADBAY_ID", "Returns the imported records WITHOUT writing LEADBAY_ID values back into the user's file (leaves the user no audit trail of what matched)", "Fabricates leadIds, contact emails, or mapping IDs not present in the file or a tool response"] },
771
- leadbay_log_outreach: { "name": "leadbay_log_outreach", "short_description": "Log outreach (an email I sent, a call I made, a meeting I had) on a\nspecific lead. Captures verification so the SDR pipeline trusts the entry.\n", "arguments": [{ "name": "lead_id", "description": "The lead UUID. Get it from leadbay_pull_leads or leadbay_research_lead.", "required": true }, { "name": "summary", "description": "1-2 sentences describing what I did (e.g. 'Sent intro email to CTO citing recent Hornsea contract').", "required": true }], "expected_calls": ["leadbay_report_outreach"], "failure_modes": ["Calls leadbay_report_outreach without first collecting a verification source", "Fabricates a gmail_message_id or calendar_event_id (the human team treats verification as canonical)", "Records outreach to a different lead_id than the one the user supplied", "Skips the dry_run step when the user is unsure what would be sent"] },
771
+ leadbay_log_outreach: { "name": "leadbay_log_outreach", "short_description": "Log outreach (an email I sent, a call I made, a meeting I had) on a\nspecific lead. Captures verification so the SDR pipeline trusts the entry.\n", "arguments": [{ "name": "lead_id", "description": "The lead UUID. Get it from leadbay_pull_leads or leadbay_research_lead_by_id.", "required": true }, { "name": "summary", "description": "1-2 sentences describing what I did (e.g. 'Sent intro email to CTO citing recent Hornsea contract').", "required": true }], "expected_calls": ["leadbay_report_outreach"], "failure_modes": ["Calls leadbay_report_outreach without first collecting a verification source", "Fabricates a gmail_message_id or calendar_event_id (the human team treats verification as canonical)", "Records outreach to a different lead_id than the one the user supplied", "Skips the dry_run step when the user is unsure what would be sent"] },
772
772
  leadbay_prospecting_overview: { "name": "leadbay_prospecting_overview", "short_description": `Orientation for working with Leadbay from any host \u2014 discovery vs.
773
773
  follow-up, the outreach loop, outcome recording, imports, pushback /
774
774
  snooze, and the connected-outreach-tool registry. Trigger when the
775
775
  conversation involves Leadbay leads, prospecting, pipeline, follow-up,
776
776
  outreach, or lens / ICP \u2014 anything from "show me my leads" to "what
777
777
  should I follow up on" to "I'll send via lemlist".
778
- `, "arguments": [], "expected_calls": ["leadbay_account_status", "leadbay_pull_leads", "leadbay_pull_followups", "leadbay_research_company", "leadbay_research_lead", "leadbay_prepare_outreach", "leadbay_report_outreach", "leadbay_set_pushback", "leadbay_remove_pushback", "leadbay_bulk_qualify_leads", "leadbay_enrich_titles", "leadbay_import_leads", "leadbay_add_note", "leadbay_adjust_audience"], "failure_modes": ['Drives outreach without asking the user "how did it go?" afterwards \u2014 leaving prospecting_actions and epilogue_status stale', 'Says "epilogue" in user-facing dialogue instead of "outcome"', 'Says "Monitor" in user-facing dialogue instead of "follow-ups"', 'Treats a "not now / next quarter" reply as a note instead of routing through the pushback mechanism', "Drafts outreach in a generic format when the user has a connected sequencer (lemlist, Outreach.io, etc.) that has its own idiom", "Re-pulls leads without passing the captured lensId, allowing a backend lens shift to discard prior work", "Skips the STOP byproduct in any multi-step workflow it triggers", "Calls leadbay_pull_leads (Discover wishlist) for a follow-up query, or leadbay_pull_followups (Monitor view) for a discovery query \u2014 the two entry points read from different backend tables; the right orchestrators are leadbay_daily_check_in (discovery) and leadbay_followup_check_in (follow-up)"] },
779
- leadbay_qualify_top_n: { "name": "leadbay_qualify_top_n", "short_description": "Bulk-qualify the top N un-qualified leads in the active lens. Uses\nleadbay_bulk_qualify_leads with a sensible default budget.\n", "arguments": [{ "name": "count", "description": "How many leads to qualify (default 10, max 25). Higher counts may take 5+ minutes.", "required": false }], "expected_calls": ["leadbay_bulk_qualify_leads", "leadbay_qualify_status", "leadbay_pull_leads", "leadbay_research_lead"], "failure_modes": ["Picks a count larger than the user asked for (or larger than the max 25)", "Glosses over still-running leads in the summary instead of naming them", "Recommends a lead from the existing qualified pool instead of one from this batch's actual results", 'Replaces the canonical pull_leads table with prose when rendering the newly-qualified batch (the per-tool RENDERING block is the structural contract; "standouts" commentary sits above it)', "Expands the qualify-status sentence into a card or table instead of the one-line status-inline render"] },
778
+ `, "arguments": [], "expected_calls": ["leadbay_account_status", "leadbay_pull_leads", "leadbay_pull_followups", "leadbay_research_lead_by_id", "leadbay_research_lead_by_name_fuzzy", "leadbay_prepare_outreach", "leadbay_report_outreach", "leadbay_set_pushback", "leadbay_remove_pushback", "leadbay_bulk_qualify_leads", "leadbay_enrich_titles", "leadbay_import_leads", "leadbay_add_note", "leadbay_adjust_audience"], "failure_modes": ['Drives outreach without asking the user "how did it go?" afterwards \u2014 leaving prospecting_actions and epilogue_status stale', 'Says "epilogue" in user-facing dialogue instead of "outcome"', 'Says "Monitor" in user-facing dialogue instead of "follow-ups"', 'Treats a "not now / next quarter" reply as a note instead of routing through the pushback mechanism', "Drafts outreach in a generic format when the user has a connected sequencer (lemlist, Outreach.io, etc.) that has its own idiom", "Re-pulls leads without passing the captured lensId, allowing a backend lens shift to discard prior work", "Skips the STOP byproduct in any multi-step workflow it triggers", "Calls leadbay_pull_leads (Discover wishlist) for a follow-up query, or leadbay_pull_followups (Monitor view) for a discovery query \u2014 the two entry points read from different backend tables; the right orchestrators are leadbay_daily_check_in (discovery) and leadbay_followup_check_in (follow-up)"] },
779
+ leadbay_qualify_top_n: { "name": "leadbay_qualify_top_n", "short_description": "Bulk-qualify the top N un-qualified leads in the active lens. Uses\nleadbay_bulk_qualify_leads with a sensible default budget.\n", "arguments": [{ "name": "count", "description": "How many leads to qualify (default 10, max 25). Higher counts may take 5+ minutes.", "required": false }], "expected_calls": ["leadbay_bulk_qualify_leads", "leadbay_qualify_status", "leadbay_pull_leads", "leadbay_research_lead_by_id"], "failure_modes": ["Picks a count larger than the user asked for (or larger than the max 25)", "Glosses over still-running leads in the summary instead of naming them", "Recommends a lead from the existing qualified pool instead of one from this batch's actual results", 'Replaces the canonical pull_leads table with prose when rendering the newly-qualified batch (the per-tool RENDERING block is the structural contract; "standouts" commentary sits above it)', "Expands the qualify-status sentence into a card or table instead of the one-line status-inline render"] },
780
780
  leadbay_refine_audience: { "name": "leadbay_refine_audience", "short_description": "Refine the kind of leads Leadbay surfaces beyond firmographics, with a\nfree-text instruction. Handles the clarification round-trip if the new\nprompt is ambiguous.\n", "arguments": [{ "name": "instruction", "description": "The refinement (e.g. 'focus on hospitals running their own IT'). Set to plain English.", "required": true }], "expected_calls": ["leadbay_refine_prompt", "leadbay_account_status"], "failure_modes": ["Calls leadbay_answer_clarification on the user's behalf instead of surfacing the clarification verbatim", "Glosses over the clarification options instead of presenting them as offered", "Promises immediate effect when status='applied' actually triggers an async intelligence recompute"] },
781
- leadbay_research_a_domain: { "name": "leadbay_research_a_domain", "short_description": "Import a company by domain and run deep qualification + research in one\npass. Use when a colleague mentions a name and you want everything Leadbay\nknows about it.\n", "arguments": [{ "name": "domain", "description": "The company's primary domain (e.g. 'acme.com'). Protocol/path are stripped.", "required": true }], "expected_calls": ["leadbay_import_and_qualify", "leadbay_research_lead"], "failure_modes": ["Fabricates qualification answers not present in any tool response", "Reports certainty about fit when qualification didn't actually run (e.g. quota_blocked)", "Skips the research step after import completes", "Renders the research_lead result as a freeform narrative instead of the canonical research-company-card layout (the card with header score bar, pill row, signal sections, contacts table is the structural contract; commentary belongs ABOVE or BELOW it)", "Enumerates every imported lead in prose instead of the terse single-record summary from the import-result rendering snippet"] }
781
+ leadbay_research_a_domain: { "name": "leadbay_research_a_domain", "short_description": "Import a company by domain and run deep qualification + research in one\npass. Use when a colleague mentions a name and you want everything Leadbay\nknows about it.\n", "arguments": [{ "name": "domain", "description": "The company's primary domain (e.g. 'acme.com'). Protocol/path are stripped.", "required": true }], "expected_calls": ["leadbay_import_and_qualify", "leadbay_research_lead_by_id"], "failure_modes": ["Fabricates qualification answers not present in any tool response", "Reports certainty about fit when qualification didn't actually run (e.g. quota_blocked)", "Skips the research step after import completes", "Renders the research_lead_by_id result as a freeform narrative instead of the canonical research-company-card layout (the card with header score bar, pill row, signal sections, contacts table is the structural contract; commentary belongs ABOVE or BELOW it)", "Enumerates every imported lead in prose instead of the terse single-record summary from the import-result rendering snippet"] }
782
782
  };
783
783
  var PROMPT_CATALOG_HEADER = `This server exposes the following workflow prompts via \`prompts/list\` and \`prompts/get\`. Some MCP clients render them as slash commands; if your client does not, you (the agent) should invoke them directly via \`prompts/get\` when the user's request matches one of the triggers described below.`;
784
784
  var PROMPT_CATALOG_BULLETS = {
@@ -882,7 +882,7 @@ var CATALOG = [
882
882
  arguments: [
883
883
  {
884
884
  name: "lead_id",
885
- description: "The lead UUID. Get it from leadbay_pull_leads or leadbay_research_lead.",
885
+ description: "The lead UUID. Get it from leadbay_pull_leads or leadbay_research_lead_by_id.",
886
886
  required: true
887
887
  },
888
888
  {
@@ -965,7 +965,7 @@ function listResourceTemplates() {
965
965
  {
966
966
  uriTemplate: "lead://{uuid}/profile",
967
967
  name: "Lead profile",
968
- description: "Full profile for a single Leadbay lead by UUID. Read-only. Cached client-side; cheaper than calling leadbay_research_lead when you already have the id.",
968
+ description: "Full profile for a single Leadbay lead by UUID. Read-only. Cached client-side; cheaper than calling leadbay_research_lead_by_id when you already have the id.",
969
969
  mimeType: "application/json"
970
970
  },
971
971
  {
@@ -1016,9 +1016,221 @@ async function readResource(uri, client) {
1016
1016
  );
1017
1017
  }
1018
1018
 
1019
+ // src/host-widgets.ts
1020
+ var BUILTIN_WIDGETS_PARAGRAPH = 'Prefer host-native widgets over inline markdown when the data shape fits. Three to know: (1) `places_map_display_v0` \u2014 for \u22652 locations / map / travel intent. Pass `{name, address, latitude, longitude, notes}` per location; the host enriches via Google Places. (2) `message_compose_v1` \u2014 for any outreach draft (email / message / call opener). Pass 2\u20133 strategic variants with goal-oriented labels ("Push for alignment", "Reference M&A signal") \u2014 NOT tone labels. (3) `ask_user_input_v0` \u2014 for the NEXT STEPS questions every Leadbay tool emits. Pass `single_select` with 2\u20134 mutually-exclusive options from the tool\'s NEXT STEPS table. When the host doesn\'t expose the named widget, fall back to the per-tool markdown RENDERING block. The directive is host-conditional; the fallback is automatic.';
1021
+
1022
+ // src/telemetry.ts
1023
+ import { PostHog } from "posthog-node";
1024
+ import * as Sentry from "@sentry/node";
1025
+
1026
+ // src/telemetry-constants.ts
1027
+ var EMBEDDED_POSTHOG_KEY = "phc_N9SnA7OULuAlXReQJZ0Y3rPI4eC0mJLpMRbzgqamhHR";
1028
+ var EMBEDDED_POSTHOG_HOST = "https://eu.i.posthog.com";
1029
+ var EMBEDDED_SENTRY_DSN = "https://301f1c433433b76132956ed5415bea19@o4505874436849664.ingest.us.sentry.io/4511419984248832";
1030
+
1031
+ // src/telemetry-events.ts
1032
+ var EV_TOOL_CALL = "mcp tool called";
1033
+ var EV_QUOTA_HIT = "mcp quota hit";
1034
+ var EV_TOPUP_LINK = "mcp topup link created";
1035
+
1036
+ // src/telemetry.ts
1037
+ var NOOP_TELEMETRY = {
1038
+ identify: async () => {
1039
+ },
1040
+ captureToolCall: () => {
1041
+ },
1042
+ captureQuotaHit: () => {
1043
+ },
1044
+ captureTopupLink: () => {
1045
+ },
1046
+ captureException: () => {
1047
+ },
1048
+ shutdown: async () => {
1049
+ }
1050
+ };
1051
+ function parseTelemetryEnv(raw) {
1052
+ if (raw === void 0 || raw === "") return true;
1053
+ const v = raw.trim().toLowerCase();
1054
+ if (v === "false" || v === "0" || v === "no" || v === "off") return false;
1055
+ return true;
1056
+ }
1057
+ function initTelemetry(opts) {
1058
+ if (!parseTelemetryEnv(process.env.LEADBAY_TELEMETRY_ENABLED)) return NOOP_TELEMETRY;
1059
+ if (process.env.NODE_ENV === "test") return NOOP_TELEMETRY;
1060
+ const posthogKey = process.env.LEADBAY_POSTHOG_KEY ?? EMBEDDED_POSTHOG_KEY;
1061
+ const sentryDsn = process.env.LEADBAY_SENTRY_DSN ?? EMBEDDED_SENTRY_DSN;
1062
+ if (!posthogKey && !sentryDsn) return NOOP_TELEMETRY;
1063
+ const { version, logger } = opts;
1064
+ const environment = process.env.LEADBAY_ENV ?? (version.includes("-dev.") ? "dev" : "production");
1065
+ let posthog = null;
1066
+ let sentryReady = false;
1067
+ let initError = null;
1068
+ try {
1069
+ if (posthogKey) {
1070
+ posthog = new PostHog(posthogKey, {
1071
+ host: process.env.LEADBAY_POSTHOG_HOST ?? EMBEDDED_POSTHOG_HOST,
1072
+ flushAt: 20,
1073
+ flushInterval: 1e4,
1074
+ disableGeoip: false
1075
+ });
1076
+ }
1077
+ } catch (err) {
1078
+ initError = err;
1079
+ posthog = null;
1080
+ }
1081
+ try {
1082
+ if (sentryDsn) {
1083
+ Sentry.init({
1084
+ dsn: sentryDsn,
1085
+ release: `@leadbay/mcp@${version}`,
1086
+ environment,
1087
+ tracesSampleRate: 0,
1088
+ profilesSampleRate: 0,
1089
+ defaultIntegrations: false,
1090
+ integrations: [Sentry.httpIntegration()],
1091
+ sendDefaultPii: false
1092
+ });
1093
+ sentryReady = true;
1094
+ }
1095
+ } catch (err) {
1096
+ initError = initError ?? err;
1097
+ sentryReady = false;
1098
+ }
1099
+ if (initError) {
1100
+ logger?.warn?.(`telemetry init failed: ${initError.message ?? initError}`);
1101
+ }
1102
+ if (!posthog && !sentryReady) return NOOP_TELEMETRY;
1103
+ let me = null;
1104
+ let identityPromise = null;
1105
+ const pendingEvents = [];
1106
+ let region = "unknown";
1107
+ const baseProps = () => ({
1108
+ mcp_version: version,
1109
+ node_version: process.versions.node,
1110
+ platform: process.platform,
1111
+ region
1112
+ });
1113
+ const distinctIdFor = () => {
1114
+ if (me?.email) return me.email;
1115
+ if (me?.id) return `mcp:user-${me.id}`;
1116
+ return "mcp:unknown";
1117
+ };
1118
+ const groupsFor = () => {
1119
+ return me?.organization?.id ? { organization: me.organization.id } : void 0;
1120
+ };
1121
+ const doCapture = (event, properties) => {
1122
+ if (!posthog) return;
1123
+ try {
1124
+ posthog.capture({
1125
+ distinctId: distinctIdFor(),
1126
+ event,
1127
+ properties: { ...baseProps(), ...properties },
1128
+ groups: groupsFor()
1129
+ });
1130
+ } catch (err) {
1131
+ logger?.warn?.(`posthog capture failed: ${err?.message ?? err}`);
1132
+ }
1133
+ };
1134
+ const emit = (event, properties) => {
1135
+ if (!posthog) return;
1136
+ if (!me) {
1137
+ pendingEvents.push({ event, properties });
1138
+ return;
1139
+ }
1140
+ doCapture(event, properties);
1141
+ };
1142
+ const flushPending = () => {
1143
+ if (!posthog || pendingEvents.length === 0) return;
1144
+ const buf = pendingEvents.splice(0, pendingEvents.length);
1145
+ for (const { event, properties } of buf) {
1146
+ doCapture(event, properties);
1147
+ }
1148
+ };
1149
+ return {
1150
+ identify(client) {
1151
+ if (identityPromise) return identityPromise;
1152
+ region = client.region;
1153
+ identityPromise = (async () => {
1154
+ try {
1155
+ const resolved = await client.resolveMe();
1156
+ me = resolved;
1157
+ if (posthog && resolved.email) {
1158
+ try {
1159
+ posthog.identify({
1160
+ distinctId: resolved.email,
1161
+ properties: {
1162
+ email: resolved.email,
1163
+ leadbay_id: resolved.id,
1164
+ leadbay_name: resolved.name,
1165
+ leadbay_organization: resolved.organization?.name,
1166
+ leadbay_organization_id: resolved.organization?.id
1167
+ }
1168
+ });
1169
+ } catch (err) {
1170
+ logger?.warn?.(`posthog identify failed: ${err?.message ?? err}`);
1171
+ }
1172
+ }
1173
+ if (sentryReady) {
1174
+ try {
1175
+ Sentry.setUser({
1176
+ id: resolved.id,
1177
+ email: resolved.email,
1178
+ username: resolved.name
1179
+ });
1180
+ } catch (err) {
1181
+ logger?.warn?.(`sentry setUser failed: ${err?.message ?? err}`);
1182
+ }
1183
+ }
1184
+ flushPending();
1185
+ } catch (err) {
1186
+ logger?.warn?.(
1187
+ `telemetry identify failed (${err?.message ?? err}); flushing events anonymously`
1188
+ );
1189
+ me = {
1190
+ id: "unknown",
1191
+ organization: { id: "unknown", name: "unknown" }
1192
+ };
1193
+ flushPending();
1194
+ }
1195
+ })();
1196
+ return identityPromise;
1197
+ },
1198
+ captureToolCall(props) {
1199
+ emit(EV_TOOL_CALL, { ...props });
1200
+ },
1201
+ captureQuotaHit(props) {
1202
+ emit(EV_QUOTA_HIT, { ...props });
1203
+ },
1204
+ captureTopupLink(props) {
1205
+ emit(EV_TOPUP_LINK, { ...props });
1206
+ },
1207
+ captureException(err, ctx) {
1208
+ if (!sentryReady) return;
1209
+ try {
1210
+ Sentry.withScope((scope) => {
1211
+ scope.setTag("tool", ctx.tool);
1212
+ if (me?.organization?.id) {
1213
+ scope.setTag("organization", me.organization.id);
1214
+ }
1215
+ Sentry.captureException(err);
1216
+ });
1217
+ } catch (e) {
1218
+ logger?.warn?.(`sentry captureException failed: ${e?.message ?? e}`);
1219
+ }
1220
+ },
1221
+ async shutdown() {
1222
+ const tasks = [];
1223
+ if (posthog) tasks.push(posthog.shutdown(2e3).catch(() => void 0));
1224
+ if (sentryReady) tasks.push(Sentry.close(2e3).catch(() => void 0));
1225
+ await Promise.allSettled(tasks);
1226
+ }
1227
+ };
1228
+ }
1229
+
1019
1230
  // src/server.ts
1020
1231
  var VERIFICATION_MANDATE = "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.";
1021
1232
  var MENTAL_MODEL_PARAGRAPH = "How 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.";
1233
+ var QUOTA_AND_TOPUP_PARAGRAPH = "Quota & top-ups: when a tool returns QUOTA_EXCEEDED / 429, the user has TWO options \u2014 wait for the window reset (daily / weekly / monthly resets shown in leadbay_account_status), OR top up AI credits (top-ups clear the throttle IMMEDIATELY \u2014 they are not subject to the same window). Always offer BOTH options; default-recommending 'wait until tomorrow' is wrong when a 30-second top-up unblocks the same call. If the host exposes leadbay_create_topup_link, OFFER it on every quota wall: 'Want me to generate a top-up link?' \u2014 when the user says yes, call leadbay_create_topup_link and surface the returned Stripe URL as a clickable link for the user to open in their browser. (Sibling leadbay_open_billing_portal is for ongoing subscription changes, not one-shot top-ups.) AFTER the user has topped up: do NOT keep refusing operations. A top-up invalidates every prior 429 and every stale 'you're at your quota' snapshot. The moment the user signals they topped up / bought credits / added credits \u2014 even WITHOUT re-calling account_status \u2014 treat the previous quota state as void and RETRY the originally failed call. (Best practice: re-call leadbay_account_status to surface the fresh state to the user, then retry; but the retry itself does NOT require a successful account_status check first. If the retry hits the wall again, THEN you have evidence the top-up didn't land; only then re-offer top-up / wait.) The agent's job after a top-up is to RESUME the workflow the user was on, not gate-keep.";
1022
1234
  function buildScoringParagraph(has) {
1023
1235
  const base = "Two 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.";
1024
1236
  const deepenTools = [];
@@ -1030,7 +1242,7 @@ function buildScoringParagraph(has) {
1030
1242
  return base;
1031
1243
  }
1032
1244
  function buildStartHereParagraph(has) {
1033
- const base = "Start 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).";
1245
+ const base = "Start with leadbay_account_status to see the user's state, then leadbay_pull_leads to surface fresh leads. Use leadbay_research_lead_by_id to dig into one lead deeply (qualification answers, signals, contacts).";
1034
1246
  const compositeNames = ["bulk_qualify_leads", "adjust_audience", "refine_prompt", "enrich_titles"].filter((n) => has(`leadbay_${n}`));
1035
1247
  if (compositeNames.length > 0) {
1036
1248
  return base + ` When the user wants more leads, narrower audience, refined criteria, or contact enrichment, use the matching composite tool (${compositeNames.join(" / ")}) \u2014 they hide lens permissions, region routing, polling, and selection state from you.`;
@@ -1113,6 +1325,7 @@ function buildServerInstructions(exposed) {
1113
1325
  parts.push(VERIFICATION_MANDATE);
1114
1326
  }
1115
1327
  parts.push(MENTAL_MODEL_PARAGRAPH);
1328
+ parts.push(QUOTA_AND_TOPUP_PARAGRAPH);
1116
1329
  parts.push(buildScoringParagraph(has));
1117
1330
  parts.push(buildStartHereParagraph(has));
1118
1331
  parts.push(buildRhythmParagraph(has));
@@ -1120,6 +1333,7 @@ function buildServerInstructions(exposed) {
1120
1333
  if (promptsCatalog) parts.push(promptsCatalog);
1121
1334
  parts.push(RESOURCES_PARAGRAPH);
1122
1335
  parts.push(buildProtocolPrimitivesParagraph(has));
1336
+ parts.push(BUILTIN_WIDGETS_PARAGRAPH);
1123
1337
  return parts.join("\n\n");
1124
1338
  }
1125
1339
  function formatErrorForLLM(err) {
@@ -1250,8 +1464,10 @@ function buildServer(client, opts = {}) {
1250
1464
  });
1251
1465
  const DEBUG_RAW = process.env.LEADBAY_DEBUG ?? "";
1252
1466
  const DEBUG_ON = DEBUG_RAW === "1" || DEBUG_RAW.toLowerCase() === "true";
1467
+ const telemetry = opts.telemetry ?? NOOP_TELEMETRY;
1468
+ const isLeadbayBusinessError = (err) => err != null && typeof err === "object" && err.error === true && typeof err.code === "string";
1253
1469
  server.setRequestHandler(CallToolRequestSchema, async (req, extra) => {
1254
- const debugStart = DEBUG_ON ? Date.now() : 0;
1470
+ const callStart = Date.now();
1255
1471
  const name = req.params.name;
1256
1472
  const tool = toolByName.get(name);
1257
1473
  if (!tool) {
@@ -1307,9 +1523,33 @@ function buildServer(client, opts = {}) {
1307
1523
  elicit
1308
1524
  });
1309
1525
  if (result && typeof result === "object" && result.error === true) {
1526
+ const envText = formatErrorForLLM(result);
1527
+ const envDur = Date.now() - callStart;
1528
+ const envCode = result.code ?? "Error";
1529
+ if (envCode === "QUOTA_EXCEEDED") {
1530
+ telemetry.captureQuotaHit({
1531
+ tool: name,
1532
+ retry_after_s: result._meta?.retry_after,
1533
+ endpoint: result._meta?.endpoint
1534
+ });
1535
+ }
1536
+ telemetry.captureToolCall({
1537
+ tool: name,
1538
+ ok: false,
1539
+ duration_ms: envDur,
1540
+ format: "error-envelope",
1541
+ bytes: envText.length,
1542
+ error_code: envCode
1543
+ });
1544
+ if (DEBUG_ON) {
1545
+ process.stderr.write(
1546
+ `[leadbay-mcp debug] tool=${name} dur=${envDur}ms ok=false code=${envCode}
1547
+ `
1548
+ );
1549
+ }
1310
1550
  return {
1311
1551
  content: [
1312
- { type: "text", text: formatErrorForLLM(result) }
1552
+ { type: "text", text: envText }
1313
1553
  ],
1314
1554
  isError: true
1315
1555
  };
@@ -1323,11 +1563,21 @@ function buildServer(client, opts = {}) {
1323
1563
  if (tool.outputSchema && env.structured !== null && typeof env.structured === "object" && !Array.isArray(env.structured)) {
1324
1564
  out.structuredContent = env.structured;
1325
1565
  }
1566
+ const mdDur = Date.now() - callStart;
1567
+ const mdBytes = env.markdown.length;
1568
+ telemetry.captureToolCall({
1569
+ tool: name,
1570
+ ok: true,
1571
+ duration_ms: mdDur,
1572
+ format: "markdown",
1573
+ bytes: mdBytes
1574
+ });
1575
+ if (name === "leadbay_create_topup_link" && typeof env.structured?.url === "string") {
1576
+ telemetry.captureTopupLink({ tool: name });
1577
+ }
1326
1578
  if (DEBUG_ON) {
1327
- const dur = Date.now() - debugStart;
1328
- const bytes = env.markdown.length;
1329
1579
  process.stderr.write(
1330
- `[leadbay-mcp debug] tool=${name} dur=${dur}ms ok=true bytes=${bytes} format=markdown
1580
+ `[leadbay-mcp debug] tool=${name} dur=${mdDur}ms ok=true bytes=${mdBytes} format=markdown
1331
1581
  `
1332
1582
  );
1333
1583
  }
@@ -1341,28 +1591,66 @@ function buildServer(client, opts = {}) {
1341
1591
  if (tool.outputSchema && result !== null && typeof result === "object" && !Array.isArray(result)) {
1342
1592
  response.structuredContent = result;
1343
1593
  }
1594
+ const okText = response.content[0]?.text ?? "";
1595
+ const okBytes = typeof okText === "string" ? okText.length : 0;
1596
+ const okDur = Date.now() - callStart;
1597
+ telemetry.captureToolCall({
1598
+ tool: name,
1599
+ ok: true,
1600
+ duration_ms: okDur,
1601
+ format: "json",
1602
+ bytes: okBytes
1603
+ });
1604
+ if (name === "leadbay_create_topup_link" && typeof result?.url === "string") {
1605
+ telemetry.captureTopupLink({ tool: name });
1606
+ }
1344
1607
  if (DEBUG_ON) {
1345
- const dur = Date.now() - debugStart;
1346
- const text = response.content[0]?.text ?? "";
1347
- const bytes = typeof text === "string" ? text.length : 0;
1348
1608
  process.stderr.write(
1349
- `[leadbay-mcp debug] tool=${name} dur=${dur}ms ok=true bytes=${bytes}
1609
+ `[leadbay-mcp debug] tool=${name} dur=${okDur}ms ok=true bytes=${okBytes}
1350
1610
  `
1351
1611
  );
1352
1612
  }
1353
1613
  return response;
1354
1614
  } catch (err) {
1615
+ const errDur = Date.now() - callStart;
1616
+ const errText = formatErrorForLLM(err);
1617
+ const code = err?.code ?? err?.name ?? "Error";
1618
+ if (isLeadbayBusinessError(err)) {
1619
+ if (err.code === "QUOTA_EXCEEDED") {
1620
+ telemetry.captureQuotaHit({
1621
+ tool: name,
1622
+ retry_after_s: err._meta?.retry_after,
1623
+ endpoint: err._meta?.endpoint
1624
+ });
1625
+ }
1626
+ telemetry.captureToolCall({
1627
+ tool: name,
1628
+ ok: false,
1629
+ duration_ms: errDur,
1630
+ format: "error-envelope",
1631
+ bytes: errText.length,
1632
+ error_code: code
1633
+ });
1634
+ } else {
1635
+ telemetry.captureException(err, { tool: name });
1636
+ telemetry.captureToolCall({
1637
+ tool: name,
1638
+ ok: false,
1639
+ duration_ms: errDur,
1640
+ format: "error-envelope",
1641
+ bytes: errText.length,
1642
+ error_code: code
1643
+ });
1644
+ }
1355
1645
  if (DEBUG_ON) {
1356
- const dur = Date.now() - debugStart;
1357
- const code = err?.code ?? err?.name ?? "Error";
1358
1646
  process.stderr.write(
1359
- `[leadbay-mcp debug] tool=${name} dur=${dur}ms ok=false code=${code}
1647
+ `[leadbay-mcp debug] tool=${name} dur=${errDur}ms ok=false code=${code}
1360
1648
  `
1361
1649
  );
1362
1650
  }
1363
1651
  return {
1364
1652
  content: [
1365
- { type: "text", text: formatErrorForLLM(err) }
1653
+ { type: "text", text: errText }
1366
1654
  ],
1367
1655
  isError: true
1368
1656
  };
@@ -1373,7 +1661,7 @@ function buildServer(client, opts = {}) {
1373
1661
 
1374
1662
  // src/bin.ts
1375
1663
  import { createRequire } from "module";
1376
- var VERSION = "0.9.1";
1664
+ var VERSION = "0.10.0";
1377
1665
  var HELP = `
1378
1666
  leadbay-mcp ${VERSION} \u2014 Leadbay Model Context Protocol server
1379
1667
 
@@ -1409,6 +1697,13 @@ ENV VARS
1409
1697
  journaled in-process and return {mocked: true, would_call: {...}}.
1410
1698
  LEADBAY_MOCK_DIR (optional) Fixture directory. Default: ./.context/leadbay-live-shapes/
1411
1699
  LEADBAY_LOG_LEVEL (optional) "debug" | "info" | "error" (default "error"). Logs to stderr.
1700
+ LEADBAY_TELEMETRY_ENABLED (optional) Default "true". Sends product usage events
1701
+ (tool name, duration, ok flag, error code) to PostHog and
1702
+ unexpected errors to Sentry, helping Leadbay improve the MCP.
1703
+ Events are tied to your Leadbay account email (so MCP usage
1704
+ consolidates with web-app usage in our analytics). Tool
1705
+ arguments, response bodies, and lead PII are NEVER captured.
1706
+ Set to "false" to opt out. See README "Privacy & telemetry".
1412
1707
 
1413
1708
  EXAMPLE Claude Desktop config (~/Library/Application Support/Claude/claude_desktop_config.json)
1414
1709
  {
@@ -1418,7 +1713,8 @@ EXAMPLE Claude Desktop config (~/Library/Application Support/Claude/claude_deskt
1418
1713
  "args": ["-y", "@leadbay/mcp@0.3"],
1419
1714
  "env": {
1420
1715
  "LEADBAY_TOKEN": "lb_...",
1421
- "LEADBAY_REGION": "us"
1716
+ "LEADBAY_REGION": "us",
1717
+ "LEADBAY_TELEMETRY_ENABLED": "true"
1422
1718
  }
1423
1719
  }
1424
1720
  }
@@ -1662,7 +1958,7 @@ async function runLogin(args) {
1662
1958
  let result;
1663
1959
  try {
1664
1960
  if (pinnedRegion && !allowFallback) {
1665
- const { REGIONS } = await import("./dist-X5AOPZ2V.js");
1961
+ const { REGIONS } = await import("./dist-BHLIJAIH.js");
1666
1962
  const baseUrl = REGIONS[pinnedRegion];
1667
1963
  const c = createClient({ region: pinnedRegion });
1668
1964
  const token = await loginAt(baseUrl, email, password);
@@ -1989,7 +2285,7 @@ async function readChoice(prompt, def = true) {
1989
2285
  process.stdin.on("data", onData);
1990
2286
  });
1991
2287
  }
1992
- function buildClaudeCodeAddArgs(token, region, includeWrite) {
2288
+ function buildClaudeCodeAddArgs(token, region, includeWrite, telemetryEnabled) {
1993
2289
  const args = [
1994
2290
  "mcp",
1995
2291
  "add",
@@ -1999,15 +2295,20 @@ function buildClaudeCodeAddArgs(token, region, includeWrite) {
1999
2295
  "--env",
2000
2296
  `LEADBAY_TOKEN=${token}`,
2001
2297
  "--env",
2002
- `LEADBAY_REGION=${region}`
2298
+ `LEADBAY_REGION=${region}`,
2299
+ // Always written explicitly (not just when opting out) so MCP-client
2300
+ // config UIs can render it as a toggle the user can flip without
2301
+ // editing the file by hand.
2302
+ "--env",
2303
+ `LEADBAY_TELEMETRY_ENABLED=${telemetryEnabled ? "true" : "false"}`
2003
2304
  ];
2004
2305
  if (!includeWrite) args.push("--env", `LEADBAY_MCP_WRITE=0`);
2005
2306
  args.push("--", "npx", "-y", "@leadbay/mcp@0.3");
2006
2307
  return args;
2007
2308
  }
2008
- async function installInClaudeCode(token, region, includeWrite) {
2309
+ async function installInClaudeCode(token, region, includeWrite, telemetryEnabled) {
2009
2310
  const cp = await import("child_process");
2010
- const args = buildClaudeCodeAddArgs(token, region, includeWrite);
2311
+ const args = buildClaudeCodeAddArgs(token, region, includeWrite, telemetryEnabled);
2011
2312
  return await new Promise((resolve) => {
2012
2313
  const child = cp.spawn("claude", args, { stdio: ["ignore", "pipe", "pipe"] });
2013
2314
  let stderr = "";
@@ -2025,7 +2326,7 @@ async function installInClaudeCode(token, region, includeWrite) {
2025
2326
  );
2026
2327
  });
2027
2328
  }
2028
- async function installInJsonConfig(configPath, token, region, includeWrite) {
2329
+ async function installInJsonConfig(configPath, token, region, includeWrite, telemetryEnabled) {
2029
2330
  try {
2030
2331
  const { readFileSync, writeFileSync, existsSync, mkdirSync, statSync } = await import("fs");
2031
2332
  const { dirname } = await import("path");
@@ -2045,7 +2346,9 @@ async function installInJsonConfig(configPath, token, region, includeWrite) {
2045
2346
  parsed.mcpServers = parsed.mcpServers ?? {};
2046
2347
  const env = {
2047
2348
  LEADBAY_TOKEN: token,
2048
- LEADBAY_REGION: region
2349
+ LEADBAY_REGION: region,
2350
+ // Always written so MCP-client config UIs can render it as a toggle.
2351
+ LEADBAY_TELEMETRY_ENABLED: telemetryEnabled ? "true" : "false"
2049
2352
  };
2050
2353
  if (!includeWrite) env.LEADBAY_MCP_WRITE = "0";
2051
2354
  parsed.mcpServers.leadbay = {
@@ -2073,7 +2376,7 @@ async function runInstall(args) {
2073
2376
  const email = parseFlag(args, "email");
2074
2377
  if (!email) {
2075
2378
  process.stderr.write(
2076
- "Usage: leadbay-mcp install --email you@example.com [--region us|fr]\n [--allow-region-fallback] [--no-write]\n [--target claude-code,claude-desktop,cursor]\n [--yes] [--force-legacy]\n Mints a token AND registers the MCP server with your installed clients (at user scope).\n --target Comma-separated subset; default = all detected.\n --no-write Disable composite write tools (refine_prompt, report_outreach,\n adjust_audience, etc.). They are ON by default since 0.3.0;\n pass --no-write for read-only agents.\n --include-write (deprecated since 0.3.0; now a no-op \u2014 writes are on by default).\n --yes Don't ask before installing into each detected client.\n --force-legacy Write to claude_desktop_config.json even when Claude Desktop 2026\n DXT is detected. Not recommended \u2014 the app overwrites that file.\n Use the .dxt bundle instead: https://github.com/leadbay/leadclaw/releases\n"
2379
+ "Usage: leadbay-mcp install --email you@example.com [--region us|fr]\n [--allow-region-fallback] [--no-write] [--no-telemetry]\n [--target claude-code,claude-desktop,cursor]\n [--yes] [--force-legacy]\n Mints a token AND registers the MCP server with your installed clients (at user scope).\n --target Comma-separated subset; default = all detected.\n --no-write Disable composite write tools (refine_prompt, report_outreach,\n adjust_audience, etc.). They are ON by default since 0.3.0;\n pass --no-write for read-only agents.\n --no-telemetry Opt out of product usage events (PostHog + Sentry).\n Defaults to ON: helps Leadbay improve the MCP. Events are\n tied to your Leadbay email; tool args, response bodies,\n and lead PII are NEVER captured.\n --include-write (deprecated since 0.3.0; now a no-op \u2014 writes are on by default).\n --yes Don't ask before installing into each detected client.\n --force-legacy Write to claude_desktop_config.json even when Claude Desktop 2026\n DXT is detected. Not recommended \u2014 the app overwrites that file.\n Use the .dxt bundle instead: https://github.com/leadbay/leadclaw/releases\n"
2077
2380
  );
2078
2381
  return 2;
2079
2382
  }
@@ -2154,7 +2457,7 @@ leadbay-mcp install \u2014 detected MCP clients on this machine:
2154
2457
  let region;
2155
2458
  try {
2156
2459
  if (pinnedRegion && !allowFallback) {
2157
- const { REGIONS } = await import("./dist-X5AOPZ2V.js");
2460
+ const { REGIONS } = await import("./dist-BHLIJAIH.js");
2158
2461
  const baseUrl = REGIONS[pinnedRegion];
2159
2462
  token = await loginAt(baseUrl, email, password);
2160
2463
  region = pinnedRegion;
@@ -2181,6 +2484,16 @@ leadbay-mcp install \u2014 detected MCP clients on this machine:
2181
2484
  "Composite write tools DISABLED (read-only agent). Re-run without --no-write to enable.\n\n"
2182
2485
  );
2183
2486
  }
2487
+ const telemetryEnabled = !hasFlag(args, "no-telemetry");
2488
+ if (telemetryEnabled) {
2489
+ process.stderr.write(
2490
+ "Product usage events ENABLED \u2014 helps Leadbay improve the MCP. We capture\n per-tool-call metrics (name, duration, ok/error code) and unexpected exceptions.\n Events are tied to your Leadbay email (so MCP usage consolidates with web-app\n usage in our analytics) \u2014 they are NOT anonymous. Tool arguments, response\n bodies, and lead PII are NEVER sent. Flip the toggle\n LEADBAY_TELEMETRY_ENABLED=false in your client's env block to opt out anytime.\n\n"
2491
+ );
2492
+ } else {
2493
+ process.stderr.write(
2494
+ "Product usage events DISABLED. Re-run without --no-telemetry to enable.\n\n"
2495
+ );
2496
+ }
2184
2497
  const skipPrompts = hasFlag(args, "yes");
2185
2498
  const results = [];
2186
2499
  for (const c of chosen) {
@@ -2200,10 +2513,10 @@ leadbay-mcp install \u2014 detected MCP clients on this machine:
2200
2513
  }
2201
2514
  let res;
2202
2515
  if (c.id === "claude-code") {
2203
- res = await installInClaudeCode(token, region, includeWrite);
2516
+ res = await installInClaudeCode(token, region, includeWrite, telemetryEnabled);
2204
2517
  } else {
2205
2518
  const path = c.detail.split(" ")[0];
2206
- res = await installInJsonConfig(path, token, region, includeWrite);
2519
+ res = await installInJsonConfig(path, token, region, includeWrite, telemetryEnabled);
2207
2520
  }
2208
2521
  results.push({ id: c.id, label: c.label, ...res });
2209
2522
  }
@@ -2290,7 +2603,9 @@ async function main() {
2290
2603
  process.exit(await runDoctor());
2291
2604
  }
2292
2605
  const logger = makeStderrLogger(parseLogLevel(process.env.LEADBAY_LOG_LEVEL));
2606
+ const telemetry = initTelemetry({ version: VERSION, logger });
2293
2607
  const client = await resolveClientFromEnv(logger);
2608
+ telemetry.identify(client);
2294
2609
  const includeAdvanced = process.env.LEADBAY_MCP_ADVANCED === "1";
2295
2610
  const includeWrite = parseWriteEnv();
2296
2611
  const bulkTracker = await createDefaultBulkStore({ logger });
@@ -2299,13 +2614,24 @@ async function main() {
2299
2614
  includeWrite,
2300
2615
  logger,
2301
2616
  bulkTracker,
2302
- version: VERSION
2617
+ version: VERSION,
2618
+ telemetry
2303
2619
  });
2304
2620
  const transport = new StdioServerTransport();
2305
2621
  logger.info?.(
2306
2622
  `Starting MCP server v${VERSION} (advanced=${includeAdvanced}, write=${includeWrite}, baseUrl=${client.baseUrl}, bulk_store=${bulkTracker.durability})`
2307
2623
  );
2308
2624
  await server.connect(transport);
2625
+ const shutdown = async (code) => {
2626
+ try {
2627
+ await telemetry.shutdown();
2628
+ } finally {
2629
+ process.exit(code);
2630
+ }
2631
+ };
2632
+ process.once("SIGINT", () => void shutdown(130));
2633
+ process.once("SIGTERM", () => void shutdown(143));
2634
+ process.stdin.once("end", () => void shutdown(0));
2309
2635
  }
2310
2636
  var isEntrypoint = (() => {
2311
2637
  try {
@@ -2318,9 +2644,15 @@ var isEntrypoint = (() => {
2318
2644
  }
2319
2645
  })();
2320
2646
  if (isEntrypoint) {
2321
- main().catch((err) => {
2647
+ main().catch(async (err) => {
2322
2648
  process.stderr.write(`leadbay-mcp: ${err?.message ?? err}
2323
2649
  `);
2650
+ try {
2651
+ const bootTelemetry = initTelemetry({ version: VERSION });
2652
+ bootTelemetry.captureException(err, { tool: "__bootstrap__" });
2653
+ await bootTelemetry.shutdown();
2654
+ } catch {
2655
+ }
2324
2656
  process.exit(1);
2325
2657
  });
2326
2658
  }