@leadbay/mcp 0.10.1 → 0.12.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
@@ -1,5 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
+ LeadbayClient,
3
4
  compositeReadTools,
4
5
  compositeWriteTools,
5
6
  createClient,
@@ -8,7 +9,7 @@ import {
8
9
  granularReadTools,
9
10
  granularWriteTools,
10
11
  resolveRegion
11
- } from "./chunk-F3EWCHME.js";
12
+ } from "./chunk-J2Y4LCFM.js";
12
13
 
13
14
  // src/bin.ts
14
15
  import { realpathSync } from "fs";
@@ -128,25 +129,20 @@ If \`qualification_summary.answered == 0\` or \`avg_qualification_boost\` is nul
128
129
 
129
130
  **Column 3 \u2014 Contact**
130
131
 
131
- \`[Contact name](LINK) \xB7 short job title\`. See linking/contact-linkedin for LINK priority and the \xB0-flag fallback.
132
+ \`[Contact name](LINK) \xB7 short job title\`. The \`[Contact name](LINK)\` markdown link wrapping is mandatory \u2014 never render the name as plain text. See linking/contact-linkedin for the URL priority (real profile \u2192 constructed people-search) and the \xB0-flag fallback.
132
133
 
133
134
  **Hide from the user (never include in any cell):** \`id\`, \`location.pos\`, \`location.country\` (unless city/state both missing), \`sector_id\`, \`is_hq\`, \`web_fetch_in_progress\`, \`enrichment_in_progress\`, \`highlighted_fields\`, \`custom_fields\`, \`contacts_count\` when 0, \`notes_count\` / \`epilogue_actions_count\` / \`prospecting_actions_count\` when 0, \`stale_at\`, \`deal_insights\`, \`social_presence\` booleans (except as the \xB0-flag signal), \`need_attention\` flags, any field whose value is the string \`"null"\`.
134
135
 
135
136
  ## Linking a contact's name
136
137
 
137
- Two LinkedIn URLs exist and must never be conflated: the **company's** LinkedIn page and an **individual person's** profile.
138
+ **MANDATORY: every contact name in your output \u2014 table cells, prose, headers, "Reach <Name>" callouts \u2014 MUST be wrapped in markdown link syntax \`[Name](URL)\`. Never render a contact name as bare text. A plain-text name is a broken contact card; the underlined name is the user's primary affordance for "take me to this person's profile". No "no URL available" exception \u2014 the search URL below is always constructable from name + company.**
138
139
 
139
- When the response carries a real contact LinkedIn URL \u2014 \`contact.linkedin_page\` is a string that starts with \`https://\` (the MCP coerces the legacy literal \`"null"\` string to real null before you see it) \u2014 link the contact's name to that URL.
140
+ URL priority (first applicable wins):
140
141
 
141
- Otherwise fall back to a LinkedIn people-search URL:
142
+ 1. **Real profile** \u2014 \`contact.linkedin_page\` when it's a string starting with \`https://\` (the MCP coerces the legacy literal \`"null"\` string to real null before you see it).
143
+ 2. **Constructed people-search** \u2014 \`https://www.linkedin.com/search/results/people/?keywords=<First>+<Last>+<Company>\`. URL-encode params. Strip Inc / LLC / Corp / Ltd / GmbH / Co / S.A. / S.L. / PLC / AG / SAS / SARL suffixes from the company. Append a trailing \` \xB0\` to the rendered name ONLY when this fallback is in use AND \`social_presence.linkedin == false\`. Never append \`\xB0\` when a real \`linkedin_page\` was used.
142
144
 
143
- \`\`\`
144
- https://www.linkedin.com/search/results/people/?keywords=<First>+<Last>+<Company>
145
- \`\`\`
146
-
147
- URL-encode the params. Strip Inc / LLC / Corp / Ltd / GmbH suffixes from the company name. Append a trailing \` \xB0\` to the rendered name ONLY when the fallback is in use AND \`social_presence.linkedin == false\` (no company LinkedIn \u2192 search may not resolve). Never append \`\xB0\` when a real \`linkedin_page\` was used.
148
-
149
- Never link a person's name to the company's LinkedIn page (and vice versa). The two surfaces are different \u2014 conflating them quietly degrades the workflow.
145
+ Never link a person's name to the company's LinkedIn page (and vice versa) \u2014 the two surfaces are different and conflating them quietly degrades the workflow.
150
146
 
151
147
  ## Linking the company
152
148
 
@@ -365,6 +361,55 @@ After I answer, call \`leadbay_report_outreach({lead_id: '{{arg:lead_id}}', note
365
361
  # PHASE 3 \u2014 CONFIRM
366
362
  Tell me the outreach was logged, name the verification.source used, and surface the response's \`outreach_id\` if present so I can refer back to it.
367
363
  `;
364
+ var leadbay_plan_tour_in_city = `
365
+ Plan a field sales tour for me in **{{arg:city}}**{{arg:date_paren}}.
366
+
367
+ GATE \u2014 DEFER TO TOOL RENDERING. When you call a Leadbay composite that ships its own RENDERING block (every composite in 0.9.0+ does), render the response using that block's recipe verbatim \u2014 score bars, glyph palette, column order, hide-list, link priorities, all of it. Do NOT substitute prose, a numbered list, or a different column structure even when an orchestrating prompt's body suggests alternate framing. Prompt-specific commentary (motivational nudges, summaries, next-action recommendations) belongs ABOVE or BELOW the canonical table, never in place of it.
368
+
369
+ If the prompt's body and the tool's RENDERING appear to conflict, the tool's RENDERING wins for the structural layout; the prompt's voice wins for the commentary that surrounds it.
370
+
371
+
372
+ # PHASE 1 \u2014 BUILD THE ITINERARY
373
+
374
+ Call \`leadbay_tour_plan({city: "{{arg:city}}"})\` with the default counts (6 follow-ups + 6 discover). If the response is \`status: "ambiguous_locations"\`, surface the candidates and ask me to pick one, then re-call with \`city_id\`.
375
+
376
+ Split the returned \`monitor_leads\` into two buckets client-side using \`last_monitor_action\`:
377
+
378
+ - **Customers** \u2014 leads with any \`last_monitor_action\` history (CONTACTED, MEETING_BOOKED, etc.). Treat as known accounts with prior engagement.
379
+ - **Qualified prospects** \u2014 leads with high \`ai_agent_lead_score\` (or \`score\`) but no recent action.
380
+
381
+ \`discover_leads\` are the **New** bucket.
382
+
383
+ Aim for a 3+3+3 split if possible. If the customers bucket has fewer than 3, fill from qualified. If discover_filter_note indicates a low match ratio for the city, mention it: "Only N/30 fresh leads matched your city" \u2014 better honest than padded.
384
+
385
+ # PHASE 2 \u2014 RENDER THE MAP
386
+
387
+ Route the union of \`monitor_leads + discover_leads\` into \`places_map_display_v0\` (when the host exposes it). Per-lead \`notes\` string:
388
+
389
+ - \`\u2605 Customer \u2014 <one-sentence sector + why-now>. Reach <name>, <role>: <bare phone>, <bare email>.\`
390
+ - \`\u2605 Qualified \u2014 <one-sentence>. Reach <name>...\`
391
+ - \`\u2726 New \u2014 <one-sentence>. Reach <name>...\`
392
+
393
+ Skip leads with \`location.pos === null\` (no coordinates \u2192 no pin) \u2014 list them as "+ N leads without coordinates" below the widget.
394
+
395
+ Below the widget, emit a chat-prose summary grouped by mode (Customers / Qualified / New), with LinkedIn-linked contact name + bare phone/email pills per lead. Use the canonical \`linking/contact-linkedin\` rules.
396
+
397
+ # PHASE 3 \u2014 DRAFT IN-AREA OUTREACH (optional, ask first)
398
+
399
+ After the map, ask me ONCE: "Want me to draft 'I'll be in {{arg:city}}{{arg:date_paren}}' outreach for the top accounts?" If I say yes, for each of the top 3 leads (1 Customer / 1 Qualified / 1 New), call \`leadbay_prepare_outreach(leadId)\` and route the draft through \`message_compose_v1\` with a single variant labeled "In-area visit" \u2014 body opens with the visit context, references the AI-summary angle, ends with a clear ask (15-min coffee / on-site stopover).
400
+
401
+ Serialize the prepare_outreach calls (max 3 in parallel \u2014 see the long-running-tools rule).
402
+
403
+ # PHASE 4 \u2014 PERSIST AS A CAMPAIGN (optional, ask first)
404
+
405
+ After drafts, ask me ONCE: "Save these 9 accounts as a campaign called '**{{arg:city}} Tour{{arg:date_dash}}**'?" If I say yes, call \`leadbay_create_campaign({lead_ids: [...all_nine_lead_ids], name: "{{arg:city}} Tour{{arg:date_dash}}"})\`. Surface the returned \`id\` + \`name\` as a confirmation line, and offer the NEXT STEPS chip "View progression" (which routes to \`leadbay_campaign_progression\`).
406
+
407
+ If I declined the campaign step, end the turn \u2014 the map + drafts are enough for an ad-hoc trip.
408
+
409
+ # PHASE 5 \u2014 STOP
410
+
411
+ Done. The map is the surface; the drafts are the action; the campaign is the persistence layer for managerial follow-up after the trip.
412
+ `;
368
413
  var leadbay_prospecting_overview = `
369
414
  # Leadbay Prospecting \u2014 Orientation
370
415
 
@@ -585,25 +630,20 @@ If \`qualification_summary.answered == 0\` or \`avg_qualification_boost\` is nul
585
630
 
586
631
  **Column 3 \u2014 Contact**
587
632
 
588
- \`[Contact name](LINK) \xB7 short job title\`. See linking/contact-linkedin for LINK priority and the \xB0-flag fallback.
633
+ \`[Contact name](LINK) \xB7 short job title\`. The \`[Contact name](LINK)\` markdown link wrapping is mandatory \u2014 never render the name as plain text. See linking/contact-linkedin for the URL priority (real profile \u2192 constructed people-search) and the \xB0-flag fallback.
589
634
 
590
635
  **Hide from the user (never include in any cell):** \`id\`, \`location.pos\`, \`location.country\` (unless city/state both missing), \`sector_id\`, \`is_hq\`, \`web_fetch_in_progress\`, \`enrichment_in_progress\`, \`highlighted_fields\`, \`custom_fields\`, \`contacts_count\` when 0, \`notes_count\` / \`epilogue_actions_count\` / \`prospecting_actions_count\` when 0, \`stale_at\`, \`deal_insights\`, \`social_presence\` booleans (except as the \xB0-flag signal), \`need_attention\` flags, any field whose value is the string \`"null"\`.
591
636
 
592
637
  ## Linking a contact's name
593
638
 
594
- Two LinkedIn URLs exist and must never be conflated: the **company's** LinkedIn page and an **individual person's** profile.
595
-
596
- When the response carries a real contact LinkedIn URL \u2014 \`contact.linkedin_page\` is a string that starts with \`https://\` (the MCP coerces the legacy literal \`"null"\` string to real null before you see it) \u2014 link the contact's name to that URL.
597
-
598
- Otherwise fall back to a LinkedIn people-search URL:
639
+ **MANDATORY: every contact name in your output \u2014 table cells, prose, headers, "Reach <Name>" callouts \u2014 MUST be wrapped in markdown link syntax \`[Name](URL)\`. Never render a contact name as bare text. A plain-text name is a broken contact card; the underlined name is the user's primary affordance for "take me to this person's profile". No "no URL available" exception \u2014 the search URL below is always constructable from name + company.**
599
640
 
600
- \`\`\`
601
- https://www.linkedin.com/search/results/people/?keywords=<First>+<Last>+<Company>
602
- \`\`\`
641
+ URL priority (first applicable wins):
603
642
 
604
- URL-encode the params. Strip Inc / LLC / Corp / Ltd / GmbH suffixes from the company name. Append a trailing \` \xB0\` to the rendered name ONLY when the fallback is in use AND \`social_presence.linkedin == false\` (no company LinkedIn \u2192 search may not resolve). Never append \`\xB0\` when a real \`linkedin_page\` was used.
643
+ 1. **Real profile** \u2014 \`contact.linkedin_page\` when it's a string starting with \`https://\` (the MCP coerces the legacy literal \`"null"\` string to real null before you see it).
644
+ 2. **Constructed people-search** \u2014 \`https://www.linkedin.com/search/results/people/?keywords=<First>+<Last>+<Company>\`. URL-encode params. Strip Inc / LLC / Corp / Ltd / GmbH / Co / S.A. / S.L. / PLC / AG / SAS / SARL suffixes from the company. Append a trailing \` \xB0\` to the rendered name ONLY when this fallback is in use AND \`social_presence.linkedin == false\`. Never append \`\xB0\` when a real \`linkedin_page\` was used.
605
645
 
606
- Never link a person's name to the company's LinkedIn page (and vice versa). The two surfaces are different \u2014 conflating them quietly degrades the workflow.
646
+ Never link a person's name to the company's LinkedIn page (and vice versa) \u2014 the two surfaces are different and conflating them quietly degrades the workflow.
607
647
 
608
648
  ## Linking the company
609
649
 
@@ -718,19 +758,14 @@ If \`qualification[]\` is non-empty, append one collapsed line: \`"Qualification
718
758
 
719
759
  ## Linking a contact's name
720
760
 
721
- Two LinkedIn URLs exist and must never be conflated: the **company's** LinkedIn page and an **individual person's** profile.
761
+ **MANDATORY: every contact name in your output \u2014 table cells, prose, headers, "Reach <Name>" callouts \u2014 MUST be wrapped in markdown link syntax \`[Name](URL)\`. Never render a contact name as bare text. A plain-text name is a broken contact card; the underlined name is the user's primary affordance for "take me to this person's profile". No "no URL available" exception \u2014 the search URL below is always constructable from name + company.**
722
762
 
723
- When the response carries a real contact LinkedIn URL \u2014 \`contact.linkedin_page\` is a string that starts with \`https://\` (the MCP coerces the legacy literal \`"null"\` string to real null before you see it) \u2014 link the contact's name to that URL.
763
+ URL priority (first applicable wins):
724
764
 
725
- Otherwise fall back to a LinkedIn people-search URL:
765
+ 1. **Real profile** \u2014 \`contact.linkedin_page\` when it's a string starting with \`https://\` (the MCP coerces the legacy literal \`"null"\` string to real null before you see it).
766
+ 2. **Constructed people-search** \u2014 \`https://www.linkedin.com/search/results/people/?keywords=<First>+<Last>+<Company>\`. URL-encode params. Strip Inc / LLC / Corp / Ltd / GmbH / Co / S.A. / S.L. / PLC / AG / SAS / SARL suffixes from the company. Append a trailing \` \xB0\` to the rendered name ONLY when this fallback is in use AND \`social_presence.linkedin == false\`. Never append \`\xB0\` when a real \`linkedin_page\` was used.
726
767
 
727
- \`\`\`
728
- https://www.linkedin.com/search/results/people/?keywords=<First>+<Last>+<Company>
729
- \`\`\`
730
-
731
- URL-encode the params. Strip Inc / LLC / Corp / Ltd / GmbH suffixes from the company name. Append a trailing \` \xB0\` to the rendered name ONLY when the fallback is in use AND \`social_presence.linkedin == false\` (no company LinkedIn \u2192 search may not resolve). Never append \`\xB0\` when a real \`linkedin_page\` was used.
732
-
733
- Never link a person's name to the company's LinkedIn page (and vice versa). The two surfaces are different \u2014 conflating them quietly degrades the workflow.
768
+ Never link a person's name to the company's LinkedIn page (and vice versa) \u2014 the two surfaces are different and conflating them quietly degrades the workflow.
734
769
 
735
770
  ## Linking the company
736
771
 
@@ -750,6 +785,160 @@ Place a 2\u20133 sentence summary ABOVE the card with:
750
785
 
751
786
  The card itself handles the signal callouts (\`\u{1F4C8} business signals\`, \`\u{1F4A1} prospecting clues\`). Do NOT re-narrate signals in prose above the card \u2014 that's what the card sections are for. Be honest about uncertainty: if any field is missing from tool responses, say "not surfaced by qualification" rather than guessing.
752
787
  `;
788
+ var leadbay_setup_team_prospecting = `
789
+ Set up manager-led prospecting for me: turn the audience into a lens, validate candidates, then persist as named campaigns.
790
+
791
+ Audience: **{{arg:audience}}**
792
+ {{arg:rep_split_block}}
793
+
794
+ GATE \u2014 DEFER TO TOOL RENDERING. When you call a Leadbay composite that ships its own RENDERING block (every composite in 0.9.0+ does), render the response using that block's recipe verbatim \u2014 score bars, glyph palette, column order, hide-list, link priorities, all of it. Do NOT substitute prose, a numbered list, or a different column structure even when an orchestrating prompt's body suggests alternate framing. Prompt-specific commentary (motivational nudges, summaries, next-action recommendations) belongs ABOVE or BELOW the canonical table, never in place of it.
795
+
796
+ If the prompt's body and the tool's RENDERING appear to conflict, the tool's RENDERING wins for the structural layout; the prompt's voice wins for the commentary that surrounds it.
797
+
798
+
799
+ # PHASE 1 \u2014 INTERPRET INTENT INTO A LENS
800
+
801
+ Call \`leadbay_refine_prompt({user_prompt: "{{arg:audience}}"})\`. This handles the clarification protocol natively \u2014 if the system needs more info (e.g. industry disambiguation, geography precision), it returns \`status: "clarification_needed"\` with options. Surface those to me; on my answer, re-call \`leadbay_refine_prompt\` until the prompt converges.
802
+
803
+ When the prompt has converged, call \`leadbay_create_lens({user_prompt: <refined>, name: "<short descriptive name>"})\` to create a draft lens, then \`leadbay_promote_lens({lensId})\` to make it the active lens.
804
+
805
+ # PHASE 2 \u2014 PULL + VALIDATE CANDIDATES
806
+
807
+ Call \`leadbay_pull_leads({count: 20, lensId: <the new lens id>})\` to surface the top 20 candidates from the freshly-created lens. Render with the canonical \`pull_leads\` table layout.
808
+
809
+ Ask me ONCE: "Want me to deep-research the top N for validation?" If yes, call \`leadbay_research_lead_by_id\` serialized over the top 3-5 (one at a time, max 3 in parallel per the long-running-tools rule). Surface a research summary per lead.
810
+
811
+ Then ask me ONCE: "Which of these should we drop?" If I name leads to drop, exclude them from the working set. The remaining is the validated set.
812
+
813
+ # PHASE 3 \u2014 DECIDE THE CAMPAIGN SHAPE
814
+
815
+ If I provided a \`rep_split\` ("one campaign per rep: John gets Tulsa, Sarah gets OKC"), partition the validated leads accordingly. If I didn't, ask ONCE: "Create one campaign for the whole batch, or split per rep / region / sector?" \u2014 surface 2-4 options via \`ask_user_input_v0\` when available, else as a bulleted list.
816
+
817
+ For each campaign-shape decision, derive a name. Templates:
818
+ - Whole batch: \`"<lens-name> \u2013 <YYYY-MM-DD>"\`
819
+ - Per rep: \`"<lens-name> \u2013 <RepName>"\`
820
+ - Per region: \`"<lens-name> \u2013 <RegionName>"\`
821
+
822
+ # PHASE 4 \u2014 PERSIST
823
+
824
+ For each campaign-shape partition, call \`leadbay_create_campaign({lead_ids: [...partition], name: "<derived>"})\`. Surface the returned \`id\` + \`name\` per campaign as a confirmation line.
825
+
826
+ # PHASE 5 \u2014 BE HONEST ABOUT SCOPE
827
+
828
+ Once the campaigns are created, surface this caveat in plain prose:
829
+
830
+ > Campaign visibility is currently scoped to the user who CREATED the campaign \u2014 the reps won't see these in their own MCP \`leadbay_list_campaigns\` calls. They CAN see them in the web UI at app.leadbay.ai \u2192 Campaigns. Cross-user MCP visibility would need backend work; flag this as a #3630 US3 product gap if your reps work primarily through MCP.
831
+
832
+ End with a NEXT STEPS chip via \`ask_user_input_v0\`: "View progression on one of these now?" \u2192 routes to \`leadbay_campaign_progression\`.
833
+
834
+ # PHASE 6 \u2014 STOP
835
+
836
+ Done. The lens is live, the validated cohort is persisted as named campaigns, and the manager knows where the cross-user-visibility gap is.
837
+ `;
838
+ var leadbay_work_campaign = `
839
+ Work my **{{arg:campaign_or_default}}** campaign as an outreach session{{arg:mode_paren}}.
840
+
841
+ GATE \u2014 DEFER TO TOOL RENDERING. When you call a Leadbay composite that ships its own RENDERING block (every composite in 0.9.0+ does), render the response using that block's recipe verbatim \u2014 score bars, glyph palette, column order, hide-list, link priorities, all of it. Do NOT substitute prose, a numbered list, or a different column structure even when an orchestrating prompt's body suggests alternate framing. Prompt-specific commentary (motivational nudges, summaries, next-action recommendations) belongs ABOVE or BELOW the canonical table, never in place of it.
842
+
843
+ If the prompt's body and the tool's RENDERING appear to conflict, the tool's RENDERING wins for the structural layout; the prompt's voice wins for the commentary that surrounds it.
844
+
845
+
846
+ # PHASE 0 \u2014 PICK THE CAMPAIGN
847
+
848
+ If I gave you a name or id, resolve it. Otherwise call \`leadbay_list_campaigns()\` and surface the active campaigns as a \`single_select\` via \`ask_user_input_v0\` (cap at 4 \u2014 sort by \`updated_at\` desc, archived hidden):
849
+
850
+ > Which campaign do you want to work?
851
+ > - <Name 1> \xB7 <N leads> \xB7 last touched <date>
852
+ > - <Name 2> \xB7 <N leads> \xB7 last touched <date>
853
+ > - \u2026
854
+
855
+ When the user picks, capture the \`campaign_id\`. If \`{{arg:campaign}}\` is a name, fuzzy-match against \`campaigns[].campaign.name\`. On ambiguous matches, surface a \`single_select\` instead of guessing.
856
+
857
+ # PHASE 1 \u2014 FETCH + ASSESS READINESS (the load-bearing phase)
858
+
859
+ Call \`leadbay_campaign_call_sheet({campaign_id})\`. The response carries \`summary\` + \`readiness\` \u2014 use them to figure out what the user CAN actually do today, then PROPOSE the right session mode rather than auto-rendering.
860
+
861
+ **Read the summary numbers**:
862
+ - \`total_leads\`, \`total_contacts\`
863
+ - \`leads_with_phone\` \u2014 can call from this many leads
864
+ - \`leads_with_email\` \u2014 can email this many
865
+ - \`leads_with_coords\` \u2014 can map this many
866
+ - \`leads_without_contacts\` \u2014 these need enrichment before any outreach is possible
867
+ - \`leads_already_contacted\` \u2014 these have prior touches; the rep may want to skip them for cold work
868
+
869
+ **Read the \`readiness\` booleans** (pre-computed thresholds):
870
+ - \`ready_for_calling\` (phone coverage \u226560%) \u2014 call session viable
871
+ - \`ready_for_emailing\` (email coverage \u226560%) \u2014 email session viable
872
+ - \`needs_enrichment\` (\u226530% no-contacts OR both phone+email coverage <40%) \u2014 enrichment recommended first
873
+ - \`travel_friendly\` (\u22655 geocoded leads AND coord coverage \u226560%) \u2014 map mode worth proposing
874
+
875
+ **One-line situation report** (always emit BEFORE the proposal):
876
+
877
+ \`\`\`
878
+ \u{1F4CB} <total_leads> leads \xB7 \u{1F4DE} <leads_with_phone> with a phone \xB7 \u2709 <leads_with_email> with an email \xB7 \u{1F5FA} <leads_with_coords> with coords \xB7 \u{1F534} <leads_without_contacts> need enrichment \xB7 \u2705 <leads_already_contacted> already touched
879
+ \`\`\`
880
+
881
+ **Then PROPOSE the right modes via \`ask_user_input_v0\`** (2-4 options, sorted by what makes the most sense for THIS campaign's data):
882
+
883
+ - "\u{1F4DE} Start calling now" \u2014 IF \`ready_for_calling\`. Top option when phones are there.
884
+ - "\u2709 Email session instead" \u2014 IF \`ready_for_emailing\` AND \`email_ratio > phone_ratio\`. Don't surface this when calling is more obvious.
885
+ - "\u{1F527} Enrich titles first" \u2014 IF \`needs_enrichment\`. Top option when most leads have no contacts. Phrase as "<N> leads have no reachable contact yet \u2014 enrich titles before we start?" so the user understands the cost.
886
+ - "\u{1F5FA} View on a map" \u2014 IF \`travel_friendly\` **AND** the user hasn't previously signaled disinterest in maps (check your conversation memory; if you've seen the user dismiss map renders before in this session or saved a "no maps" preference, drop this option).
887
+
888
+ If the MCP prompt argument \`mode\` was actually supplied, skip the proposal and jump to the matching mode below. If \`mode\` was omitted, do not treat \`call_sheet\` as implicit user consent \u2014 propose first.
889
+
890
+ # PHASE 2A \u2014 CALL-SHEET MODE (default after "\u{1F4DE} Start calling now")
891
+
892
+ Render per the \`leadbay_campaign_call_sheet\` RENDERING block \u2014 one CARD per lead with the 4-col contact table (Contact / Phone / Role / Recent). The phone in column 2 MUST be \`[bare](tel:URL)\` (use \`contact.phone_tel_url\` verbatim \u2014 the composite has already canonicalized it). The contact name in column 1 MUST be \`[Name](linkedin_url)\`. Email stacks under the name when present (\`\u2709 [email](mailto_url)\`). Recent stacks \`\u{1F4DD} last note\` + \`\u{1F4DE} last_action_headline\`.
893
+
894
+ End the turn with the standby line:
895
+
896
+ > Ready to start calling. Tell me what happened after each call \u2014 I'll record the note + outcome.
897
+
898
+ # PHASE 2B \u2014 EMAIL-SHEET MODE (after "\u2709 Email session instead")
899
+
900
+ Same data, slightly different render emphasis: drop the Phone column, put \`\u2709 [email](mailto_url)\` as column 2. Below each lead's table, generate a SUGGESTED short email draft per the next-step \u2014 but DON'T send. Drafts are for the user to copy-paste / send themselves.
901
+
902
+ # PHASE 2C \u2014 ENRICH-FIRST MODE (after "\u{1F527} Enrich titles first")
903
+
904
+ Extract \`leadIds\` from \`sheet.leads[].lead_id\`, then call \`leadbay_enrich_titles({leadIds, \u2026})\` (consult its description for titles / email / phone selection; do not pass \`campaign_id\`, because that is not part of the tool schema). Surface progress to the user. When complete, automatically loop back to Phase 1 (re-fetch the call sheet, re-assess readiness, re-propose).
905
+
906
+ # PHASE 2D \u2014 MAP MODE (after "\u{1F5FA} View on a map")
907
+
908
+ Pass \`response.map_locations\` directly to \`places_map_display_v0\` \u2014 the composite has already built the per-pin notes string with the top contact's phone inline. After the widget, emit the standard 4-col card list anyway so the rich detail is still scannable.
909
+
910
+ # PHASE 3 \u2014 RECORD OUTCOMES, ONE AT A TIME (after the user starts dictating)
911
+
912
+ When the user says something like *"Called Bree, voicemail, trying again Tuesday"* or *"Talked to John, wants pricing sent next week"*, parse:
913
+
914
+ 1. **Which lead** \u2014 by company name OR contact name (cross-reference with the cards you just rendered).
915
+ 2. **The note** \u2014 the user's exact words about what happened (the SDR's voice \u2014 don't paraphrase).
916
+ 3. **The outcome** \u2014 pick ONE of these four epilogue values based on what the user said:
917
+ - \`STILL_CHASING\` \u2014 pursuing, no decision yet ("trying again", "they'll get back to me")
918
+ - \`COULD_NOT_REACH_STILL_TRYING\` \u2014 voicemail, no answer, wrong number, gatekeeper blocked
919
+ - \`INTEREST_VALIDATED_OR_MEETING_PLANED\` \u2014 meeting booked, quote requested, "send me more info"
920
+ - \`NOT_INTERESTED_LOST\` \u2014 declined, "not now", "not a fit", "remove from list"
921
+
922
+ Call \`leadbay_report_outreach({lead_id, note: <user's words>, epilogue_status: <picked>, verification: {source: "user_confirmed", ref: <user's exact words verbatim>}})\`. Confirm in ONE line: *"\u2705 Logged: <Company> \u2192 <epilogue>. Next?"*
923
+
924
+ Then wait for the next dictation. Don't ask "anything else?" \u2014 just acknowledge and wait.
925
+
926
+ # PHASE 4 \u2014 STOP
927
+
928
+ When the user says "done" / "that's it" / "wrapping up" / similar, surface a session summary chip:
929
+
930
+ > Session complete \u2014 N calls logged: X meetings booked \xB7 Y still chasing \xB7 Z couldn't reach \xB7 W declined.
931
+
932
+ Optional: offer to review the \`leadbay_campaign_progression\` for the same campaign to see the updated counts.
933
+
934
+ # Iron laws
935
+
936
+ - The \`verification\` field on \`leadbay_report_outreach\` is REQUIRED. For calls (no message id), always use \`{source: "user_confirmed", ref: <user's verbatim words>}\`. Skipping it is forbidden; fabricating a gmail_message_id for a call is forbidden.
937
+ - ONE call \u2192 ONE \`leadbay_report_outreach\` invocation. Don't batch; each call has its own note + outcome.
938
+ - Map mode is OPT-IN, never automatic. The user invokes it via the proposal options or by passing \`mode=map\`.
939
+ - If you've seen the user dismiss / dislike map renders earlier in the session, don't propose map mode again.
940
+ - If the user dictates an outcome that doesn't cleanly map to one of the four epilogue values, ASK ONCE before guessing.
941
+ `;
753
942
  var PROMPT_META = {
754
943
  leadbay_daily_check_in: { "name": "leadbay_daily_check_in", "short_description": `Run the canonical daily check-in: account state, fresh batch, triage
755
944
  top 10, deep-dive every promising one, offer contact enrichment. The
@@ -769,6 +958,7 @@ anything that implies pre-existing pipeline context.
769
958
  `, "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
959
  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
960
  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"] },
961
+ leadbay_plan_tour_in_city: { "name": "leadbay_plan_tour_in_city", "short_description": "Plan a field sales tour: in one flow, surface follow-ups + fresh\nDiscover leads in the target city via `leadbay_tour_plan`, render\nto a map, draft in-area outreach via `leadbay_prepare_outreach`,\nand optionally persist the selected accounts as a named campaign\nvia `leadbay_create_campaign`. Closes #3630 US1 end-to-end.\n", "arguments": [{ "name": "city", "description": "City or region the user is visiting (e.g. 'Limoges', 'Bay Area'). Used as the geo filter for both Monitor and Discover lookups.", "required": true }, { "name": "date", "description": "When the visit is (e.g. 'May 24', 'next Thursday'). Surfaced in the outreach drafts as 'I'll be in <city> on <date>'.", "required": false }], "expected_calls": ["leadbay_tour_plan", "leadbay_research_lead_by_id", "leadbay_prepare_outreach", "leadbay_create_campaign"], "failure_modes": ["Calls leadbay_followups_map (Monitor-only) instead of leadbay_tour_plan \u2014 loses the Discover (fresh-lead) half that the user explicitly asked for", "Calls leadbay_pull_leads then drops the geo filter \u2014 returns the lens-wide wishlist instead of city-relevant fresh leads", 'Skips the campaign-persist step ("would you like to save these as a tour?") \u2014 leaves the rep with a one-shot map but no follow-up artifact', "Creates a campaign WITHOUT asking the user first \u2014 the persist step is high-intent; offer it, don't assume", "Fabricates lead_ids when seeding the campaign instead of using the ids returned by tour_plan"] },
772
962
  leadbay_prospecting_overview: { "name": "leadbay_prospecting_overview", "short_description": `Orientation for working with Leadbay from any host \u2014 discovery vs.
773
963
  follow-up, the outreach loop, outcome recording, imports, pushback /
774
964
  snooze, and the connected-outreach-tool registry. Trigger when the
@@ -778,7 +968,9 @@ should I follow up on" to "I'll send via lemlist".
778
968
  `, "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
969
  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
970
  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_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"] }
971
+ 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"] },
972
+ leadbay_setup_team_prospecting: { "name": "leadbay_setup_team_prospecting", "short_description": "Manager-led prospecting setup: conversationally turn a natural-language\naudience ask into a Leadbay lens, validate the candidate leads, and\npersist them as one or more named campaigns the rep(s) can work\nthrough. Closes #3630 US3 end-to-end (within the current\ncreator-scoped campaign visibility model).\n", "arguments": [{ "name": "audience", "description": "Natural-language audience description (e.g. 'plumbing companies with 10-50 employees in Seine-Maritime'). The lens-creation step (`leadbay_refine_prompt` \u2192 `leadbay_create_lens`) interprets it.", "required": true }, { "name": "rep_split", "description": "Optional: how to split the validated leads into per-rep campaigns. Free text \u2014 e.g. 'split by city' or 'one campaign per rep: John gets Tulsa, Sarah gets OKC'.", "required": false }], "expected_calls": ["leadbay_refine_prompt", "leadbay_create_lens", "leadbay_promote_lens", "leadbay_pull_leads", "leadbay_research_lead_by_id", "leadbay_create_campaign", "leadbay_add_leads_to_campaign"], "failure_modes": ["Skips the validation step \u2014 creates a campaign of unvetted leads from a freshly-created lens without giving the manager a chance to drop weak fits", "Creates ONE campaign for all reps without asking about the split \u2014 the user explicitly mentioned per-rep distribution and the prompt should honor it", "Pretends the backend supports cross-user assignment \u2014 campaigns are owned by the caller (creator-scoped). Surface this honestly instead of fabricating an assignment model", "Asks ALL clarifying questions inline before tool calls \u2014 instead, run the lens refinement loop with `leadbay_refine_prompt` which handles the clarification protocol natively"] },
973
+ leadbay_work_campaign: { "name": "leadbay_work_campaign", "short_description": "Work a campaign as a real outreach session: pick the campaign,\nassess what the user has (phones / emails / coords), then PROPOSE\nthe right session mode (call sheet, email sheet, enrich titles\nfirst, map). After they pick, render \u2014 and as they dictate\noutcomes per lead, record both note + epilogue via\n`leadbay_report_outreach` in one round trip.\n", "arguments": [{ "name": "campaign", "description": "Campaign name (fuzzy match against your own campaigns) or campaign UUID. Omit to list and pick interactively.", "required": false }, { "name": "mode", "description": "Optional: skip the readiness-assessment proposal and jump directly into 'call_sheet' / 'email_sheet' / 'map' / 'enrich_first'. Omit (recommended) and let the prompt propose based on the data.", "required": false }], "expected_calls": ["leadbay_list_campaigns", "leadbay_campaign_call_sheet", "leadbay_enrich_titles", "leadbay_report_outreach"], "failure_modes": ["Renders the call sheet immediately without proposing the right mode \u2014 if 60% of leads have no contacts, calling is futile; enrich first. Always assess `readiness` first.", "Auto-renders the map widget without asking \u2014 maps are intrusive when the user just wants to scroll a list. Map mode is a proposed option, not a default.", "Proposes map mode after the user has previously said they don't like maps \u2014 check conversation memory before adding 'View on a map' to the options list.", "Calls `leadbay_campaign_progression` instead of `leadbay_campaign_call_sheet` \u2014 progression has counts but no phones / LinkedIn / call-ready data; the user can't actually dial from progression rows.", "Renders contacts WITHOUT making the phone number a `[bare](tel:URL)` link \u2014 on mobile that breaks one-tap calling, which is the whole point of the cheat sheet.", "Records outreach WITHOUT epilogue_status \u2014 leaves the lead's pipeline state unchanged; the rep then sees the same lead surfaced again next session.", "Records outreach WITHOUT verification \u2014 verification.source/ref is REQUIRED. For calls, pass `{source: 'user_confirmed', ref: <user's exact words>}`.", "Loops through ALL leads in a 50-lead campaign before recording any outreach \u2014 the call-then-record loop must be per-lead, not batched."] }
782
974
  };
783
975
  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
976
  var PROMPT_CATALOG_BULLETS = {
@@ -786,10 +978,13 @@ var PROMPT_CATALOG_BULLETS = {
786
978
  leadbay_followup_check_in: `- \`leadbay_followup_check_in\`: Run the canonical follow-up check-in: surface KNOWN leads from the Monitor view that need re-engagement today, ranked by AI urgency, with the canonical pull_followups table layout. Trigger when the user asks "follow up", "already known leads", "leads I haven't contacted", "leads in [city]", "before my trip", "this week", "this month", "what's overdue", "who should I re-engage", or anything that implies pre-existing pipeline context.`,
787
979
  leadbay_import_file: `- \`leadbay_import_file\` (optional args: file, instruction): Import a user-supplied CSV/file into Leadbay through five phases with evidence gates \u2014 scan, derive, resolve identities, preserve & commit, then optionally qualify and report. The job is to maximize how many rows the Leadbay system actually ingests and matches.`,
788
980
  leadbay_log_outreach: `- \`leadbay_log_outreach\` (required args: lead_id, summary): Log outreach (an email I sent, a call I made, a meeting I had) on a specific lead. Captures verification so the SDR pipeline trusts the entry.`,
981
+ leadbay_plan_tour_in_city: `- \`leadbay_plan_tour_in_city\` (required args: city; optional args: date): Plan a field sales tour: in one flow, surface follow-ups + fresh Discover leads in the target city via \`leadbay_tour_plan\`, render to a map, draft in-area outreach via \`leadbay_prepare_outreach\`, and optionally persist the selected accounts as a named campaign via \`leadbay_create_campaign\`. Closes #3630 US1 end-to-end.`,
789
982
  leadbay_prospecting_overview: `- \`leadbay_prospecting_overview\`: Orientation for working with Leadbay from any host \u2014 discovery vs. follow-up, the outreach loop, outcome recording, imports, pushback / snooze, and the connected-outreach-tool registry. Trigger when the conversation involves Leadbay leads, prospecting, pipeline, follow-up, outreach, or lens / ICP \u2014 anything from "show me my leads" to "what should I follow up on" to "I'll send via lemlist".`,
790
983
  leadbay_qualify_top_n: `- \`leadbay_qualify_top_n\` (optional args: count): Bulk-qualify the top N un-qualified leads in the active lens. Uses leadbay_bulk_qualify_leads with a sensible default budget.`,
791
984
  leadbay_refine_audience: `- \`leadbay_refine_audience\` (required args: instruction): Refine the kind of leads Leadbay surfaces beyond firmographics, with a free-text instruction. Handles the clarification round-trip if the new prompt is ambiguous.`,
792
- leadbay_research_a_domain: `- \`leadbay_research_a_domain\` (required args: domain): Import a company by domain and run deep qualification + research in one pass. Use when a colleague mentions a name and you want everything Leadbay knows about it.`
985
+ leadbay_research_a_domain: `- \`leadbay_research_a_domain\` (required args: domain): Import a company by domain and run deep qualification + research in one pass. Use when a colleague mentions a name and you want everything Leadbay knows about it.`,
986
+ leadbay_setup_team_prospecting: `- \`leadbay_setup_team_prospecting\` (required args: audience; optional args: rep_split): Manager-led prospecting setup: conversationally turn a natural-language audience ask into a Leadbay lens, validate the candidate leads, and persist them as one or more named campaigns the rep(s) can work through. Closes #3630 US3 end-to-end (within the current creator-scoped campaign visibility model).`,
987
+ leadbay_work_campaign: `- \`leadbay_work_campaign\` (optional args: campaign, mode): Work a campaign as a real outreach session: pick the campaign, assess what the user has (phones / emails / coords), then PROPOSE the right session mode (call sheet, email sheet, enrich titles first, map). After they pick, render \u2014 and as they dictate outcomes per lead, record both note + epilogue via \`leadbay_report_outreach\` in one round trip.`
793
988
  };
794
989
 
795
990
  // src/prompts.ts
@@ -900,6 +1095,80 @@ var CATALOG = [
900
1095
  )
901
1096
  ]
902
1097
  },
1098
+ {
1099
+ name: "leadbay_plan_tour_in_city",
1100
+ description: PROMPT_META.leadbay_plan_tour_in_city.short_description,
1101
+ arguments: [
1102
+ {
1103
+ name: "city",
1104
+ description: "City or region the user is visiting (e.g. 'Limoges', 'Bay Area'). Used as the geo filter for both Monitor and Discover lookups.",
1105
+ required: true
1106
+ },
1107
+ {
1108
+ name: "date",
1109
+ description: "When the visit is (e.g. 'May 24', 'next Thursday'). Surfaced in the outreach drafts as 'I'll be in <city> on <date>'.",
1110
+ required: false
1111
+ }
1112
+ ],
1113
+ render: (args) => [
1114
+ userMessage(
1115
+ substitutePlaceholders(leadbay_plan_tour_in_city, {
1116
+ city: args.city ?? "<missing>",
1117
+ date_paren: args.date ? ` on ${args.date}` : "",
1118
+ date_dash: args.date ? ` \u2013 ${args.date}` : ""
1119
+ })
1120
+ )
1121
+ ]
1122
+ },
1123
+ {
1124
+ name: "leadbay_setup_team_prospecting",
1125
+ description: PROMPT_META.leadbay_setup_team_prospecting.short_description,
1126
+ arguments: [
1127
+ {
1128
+ name: "audience",
1129
+ description: "Natural-language audience description (e.g. 'plumbing companies with 10-50 employees in Seine-Maritime').",
1130
+ required: true
1131
+ },
1132
+ {
1133
+ name: "rep_split",
1134
+ description: "Optional: how to split validated leads into per-rep campaigns. Free text (e.g. 'split by city', 'one campaign per rep').",
1135
+ required: false
1136
+ }
1137
+ ],
1138
+ render: (args) => [
1139
+ userMessage(
1140
+ substitutePlaceholders(leadbay_setup_team_prospecting, {
1141
+ audience: args.audience ?? "<missing>",
1142
+ rep_split_block: args.rep_split ? `Rep split preference: **${args.rep_split}**
1143
+ ` : ""
1144
+ })
1145
+ )
1146
+ ]
1147
+ },
1148
+ {
1149
+ name: "leadbay_work_campaign",
1150
+ description: PROMPT_META.leadbay_work_campaign.short_description,
1151
+ arguments: [
1152
+ {
1153
+ name: "campaign",
1154
+ description: "Campaign name (fuzzy match) or campaign UUID. Omit to list and pick interactively.",
1155
+ required: false
1156
+ },
1157
+ {
1158
+ name: "mode",
1159
+ description: "Optional: skip readiness proposal and jump to 'call_sheet', 'email_sheet', 'map', or 'enrich_first'. Omit to let the prompt propose based on campaign data.",
1160
+ required: false
1161
+ }
1162
+ ],
1163
+ render: (args) => [
1164
+ userMessage(
1165
+ substitutePlaceholders(leadbay_work_campaign, {
1166
+ campaign_or_default: args.campaign ?? "<pick from the list>",
1167
+ mode_paren: args.mode ? ` (mode: ${args.mode})` : ""
1168
+ })
1169
+ )
1170
+ ]
1171
+ },
903
1172
  {
904
1173
  name: "leadbay_qualify_top_n",
905
1174
  description: PROMPT_META.leadbay_qualify_top_n.short_description,
@@ -1032,6 +1301,12 @@ var EMBEDDED_SENTRY_DSN = "https://301f1c433433b76132956ed5415bea19@o45058744368
1032
1301
  var EV_TOOL_CALL = "mcp tool called";
1033
1302
  var EV_QUOTA_HIT = "mcp quota hit";
1034
1303
  var EV_TOPUP_LINK = "mcp topup link created";
1304
+ var EV_STARTUP = "mcp startup";
1305
+ var EV_MCP_UPDATE_CHECK = "mcp update check";
1306
+ var EV_MCP_UPDATE_PROMPTED = "mcp update prompted";
1307
+ var EV_MCP_UPDATE_INSTALL_CLICKED = "mcp update install_clicked";
1308
+ var EV_MCP_UPDATE_DISMISSED = "mcp update dismissed";
1309
+ var EV_MCP_VERSION_UPDATED = "mcp version updated";
1035
1310
 
1036
1311
  // src/telemetry.ts
1037
1312
  var NOOP_TELEMETRY = {
@@ -1043,8 +1318,20 @@ var NOOP_TELEMETRY = {
1043
1318
  },
1044
1319
  captureTopupLink: () => {
1045
1320
  },
1321
+ captureStartup: () => {
1322
+ },
1046
1323
  captureException: () => {
1047
1324
  },
1325
+ captureUpdateCheck: () => {
1326
+ },
1327
+ captureUpdatePrompted: () => {
1328
+ },
1329
+ captureUpdateInstallClicked: () => {
1330
+ },
1331
+ captureUpdateDismissed: () => {
1332
+ },
1333
+ captureVersionUpdated: () => {
1334
+ },
1048
1335
  shutdown: async () => {
1049
1336
  }
1050
1337
  };
@@ -1088,7 +1375,12 @@ function initTelemetry(opts) {
1088
1375
  profilesSampleRate: 0,
1089
1376
  defaultIntegrations: false,
1090
1377
  integrations: [Sentry.httpIntegration()],
1091
- sendDefaultPii: false
1378
+ sendDefaultPii: false,
1379
+ // Tag every captured event with the surface so Sentry views can
1380
+ // split MCP issues from web-app issues without per-call work.
1381
+ initialScope: {
1382
+ tags: { source: "mcp" }
1383
+ }
1092
1384
  });
1093
1385
  sentryReady = true;
1094
1386
  }
@@ -1105,6 +1397,11 @@ function initTelemetry(opts) {
1105
1397
  const pendingEvents = [];
1106
1398
  let region = "unknown";
1107
1399
  const baseProps = () => ({
1400
+ // Always tag MCP-originated events so PostHog dashboards can split
1401
+ // MCP usage from the web app and any future surfaces. The value
1402
+ // ("mcp") is the canonical source identifier — match it in any
1403
+ // PostHog filter or insight that should isolate the MCP surface.
1404
+ source: "mcp",
1108
1405
  mcp_version: version,
1109
1406
  node_version: process.versions.node,
1110
1407
  platform: process.platform,
@@ -1204,6 +1501,24 @@ function initTelemetry(opts) {
1204
1501
  captureTopupLink(props) {
1205
1502
  emit(EV_TOPUP_LINK, { ...props });
1206
1503
  },
1504
+ captureStartup(props) {
1505
+ emit(EV_STARTUP, { ...props });
1506
+ },
1507
+ captureUpdateCheck(props) {
1508
+ emit(EV_MCP_UPDATE_CHECK, { ...props });
1509
+ },
1510
+ captureUpdatePrompted(props) {
1511
+ emit(EV_MCP_UPDATE_PROMPTED, { ...props });
1512
+ },
1513
+ captureUpdateInstallClicked(props) {
1514
+ emit(EV_MCP_UPDATE_INSTALL_CLICKED, { ...props });
1515
+ },
1516
+ captureUpdateDismissed(props) {
1517
+ emit(EV_MCP_UPDATE_DISMISSED, { ...props });
1518
+ },
1519
+ captureVersionUpdated(props) {
1520
+ emit(EV_MCP_VERSION_UPDATED, { ...props });
1521
+ },
1207
1522
  captureException(err, ctx) {
1208
1523
  if (!sentryReady) return;
1209
1524
  try {
@@ -1227,6 +1542,327 @@ function initTelemetry(opts) {
1227
1542
  };
1228
1543
  }
1229
1544
 
1545
+ // src/update-check.ts
1546
+ var cachedInfo = null;
1547
+ var checkInFlight = false;
1548
+ function getCachedUpdateInfo() {
1549
+ return cachedInfo;
1550
+ }
1551
+ var RELEASES_LATEST_URL = "https://api.github.com/repos/leadbay/leadclaw/releases/latest";
1552
+ var CHECK_THROTTLE_MS = 24 * 60 * 60 * 1e3;
1553
+ var FETCH_TIMEOUT_MS = 5e3;
1554
+ var USER_AGENT = "leadbay-mcp-update-check";
1555
+ function parseTagName(tag) {
1556
+ const stripped = tag.replace(/^mcp-v?/, "").replace(/^v/, "");
1557
+ if (!/^\d+\.\d+\.\d+/.test(stripped)) return null;
1558
+ return stripped;
1559
+ }
1560
+ function compareSemver(a, b) {
1561
+ const [aCore, aPre] = a.split("-", 2);
1562
+ const [bCore, bPre] = b.split("-", 2);
1563
+ const aParts = aCore.split(".").map((n) => parseInt(n, 10));
1564
+ const bParts = bCore.split(".").map((n) => parseInt(n, 10));
1565
+ for (let i = 0; i < 3; i++) {
1566
+ const av = aParts[i] ?? 0;
1567
+ const bv = bParts[i] ?? 0;
1568
+ if (av > bv) return 1;
1569
+ if (av < bv) return -1;
1570
+ }
1571
+ if (!aPre && !bPre) return 0;
1572
+ if (!aPre && bPre) return 1;
1573
+ if (aPre && !bPre) return -1;
1574
+ const aIds = aPre.split(".");
1575
+ const bIds = bPre.split(".");
1576
+ const len = Math.max(aIds.length, bIds.length);
1577
+ for (let i = 0; i < len; i++) {
1578
+ const ai = aIds[i];
1579
+ const bi = bIds[i];
1580
+ if (ai === void 0) return -1;
1581
+ if (bi === void 0) return 1;
1582
+ const aNum = /^\d+$/.test(ai);
1583
+ const bNum = /^\d+$/.test(bi);
1584
+ if (aNum && bNum) {
1585
+ const d = parseInt(ai, 10) - parseInt(bi, 10);
1586
+ if (d !== 0) return d > 0 ? 1 : -1;
1587
+ } else if (aNum !== bNum) {
1588
+ return aNum ? -1 : 1;
1589
+ } else if (ai !== bi) {
1590
+ return ai > bi ? 1 : -1;
1591
+ }
1592
+ }
1593
+ return 0;
1594
+ }
1595
+ function pickMcpbAsset(rel) {
1596
+ if (!Array.isArray(rel.assets)) return void 0;
1597
+ const mcpb = rel.assets.find(
1598
+ (a) => typeof a.name === "string" && a.name.endsWith(".mcpb")
1599
+ );
1600
+ if (mcpb?.browser_download_url) return mcpb.browser_download_url;
1601
+ const dxt = rel.assets.find(
1602
+ (a) => typeof a.name === "string" && a.name.endsWith(".dxt")
1603
+ );
1604
+ return dxt?.browser_download_url;
1605
+ }
1606
+ async function checkForUpdate(opts) {
1607
+ if (checkInFlight) return cachedInfo;
1608
+ checkInFlight = true;
1609
+ try {
1610
+ return await doCheck(opts);
1611
+ } finally {
1612
+ checkInFlight = false;
1613
+ }
1614
+ }
1615
+ async function doCheck(opts) {
1616
+ const now = opts.now ?? Date.now;
1617
+ const url = opts.releasesUrl ?? RELEASES_LATEST_URL;
1618
+ const fetchImpl = opts.fetchImpl ?? fetch;
1619
+ const currentVersion = opts.currentVersion;
1620
+ const state = await opts.stateStore.read();
1621
+ const within = now() - state.last_check_time < CHECK_THROTTLE_MS;
1622
+ if (within && state.latest_known_version && state.latest_known_mcpb_url && state.latest_known_release_url) {
1623
+ const cached = buildInfoIfUpgrade(
1624
+ currentVersion,
1625
+ state.latest_known_version,
1626
+ state.latest_known_mcpb_url,
1627
+ state.latest_known_release_url,
1628
+ state.suppressed_versions,
1629
+ state.remind_until,
1630
+ now()
1631
+ );
1632
+ cachedInfo = cached;
1633
+ return cached;
1634
+ }
1635
+ let status;
1636
+ let body = null;
1637
+ let nextEtag;
1638
+ try {
1639
+ const ctrl = new AbortController();
1640
+ const timer = setTimeout(() => ctrl.abort(), FETCH_TIMEOUT_MS);
1641
+ let resp;
1642
+ try {
1643
+ resp = await fetchImpl(url, {
1644
+ method: "GET",
1645
+ headers: {
1646
+ Accept: "application/vnd.github+json",
1647
+ "User-Agent": USER_AGENT,
1648
+ ...state.etag ? { "If-None-Match": state.etag } : {}
1649
+ },
1650
+ signal: ctrl.signal
1651
+ });
1652
+ } finally {
1653
+ clearTimeout(timer);
1654
+ }
1655
+ status = resp.status;
1656
+ nextEtag = resp.headers.get("etag") ?? state.etag;
1657
+ if (status === 200) {
1658
+ body = await resp.json();
1659
+ }
1660
+ } catch (err) {
1661
+ opts.logger?.warn?.(
1662
+ `update_check.fetch_failed ${err?.message ?? err}`
1663
+ );
1664
+ opts.telemetry.captureUpdateCheck?.({
1665
+ current_version: currentVersion,
1666
+ check_error: String(err?.message ?? err)
1667
+ });
1668
+ await opts.stateStore.update((cur) => ({ ...cur, last_check_time: now() }));
1669
+ return null;
1670
+ }
1671
+ if (status !== 200 && status !== 304) {
1672
+ opts.telemetry.captureUpdateCheck?.({
1673
+ current_version: currentVersion,
1674
+ check_error: `http_${status}`
1675
+ });
1676
+ await opts.stateStore.update((cur) => ({ ...cur, last_check_time: now() }));
1677
+ return null;
1678
+ }
1679
+ let latestVersion;
1680
+ let mcpbUrl;
1681
+ let releaseUrl;
1682
+ if (status === 200 && body) {
1683
+ const parsed = body.tag_name ? parseTagName(body.tag_name) : null;
1684
+ if (parsed) {
1685
+ latestVersion = parsed;
1686
+ mcpbUrl = pickMcpbAsset(body);
1687
+ releaseUrl = body.html_url;
1688
+ }
1689
+ } else {
1690
+ latestVersion = state.latest_known_version;
1691
+ mcpbUrl = state.latest_known_mcpb_url;
1692
+ releaseUrl = state.latest_known_release_url;
1693
+ }
1694
+ const persisted = await opts.stateStore.update((cur) => ({
1695
+ ...cur,
1696
+ last_check_time: now(),
1697
+ etag: nextEtag,
1698
+ latest_known_version: latestVersion ?? cur.latest_known_version,
1699
+ latest_known_mcpb_url: mcpbUrl ?? cur.latest_known_mcpb_url,
1700
+ latest_known_release_url: releaseUrl ?? cur.latest_known_release_url
1701
+ }));
1702
+ opts.telemetry.captureUpdateCheck?.({
1703
+ current_version: currentVersion,
1704
+ latest_version: persisted.latest_known_version
1705
+ });
1706
+ const info = buildInfoIfUpgrade(
1707
+ currentVersion,
1708
+ persisted.latest_known_version,
1709
+ persisted.latest_known_mcpb_url,
1710
+ persisted.latest_known_release_url,
1711
+ persisted.suppressed_versions,
1712
+ persisted.remind_until,
1713
+ now()
1714
+ );
1715
+ cachedInfo = info;
1716
+ return info;
1717
+ }
1718
+ function buildInfoIfUpgrade(currentVersion, latestVersion, mcpbUrl, releaseUrl, suppressed, remindUntil, nowMs) {
1719
+ if (!latestVersion || !mcpbUrl || !releaseUrl) return null;
1720
+ if (compareSemver(latestVersion, currentVersion) <= 0) return null;
1721
+ if (suppressed.includes(latestVersion)) return null;
1722
+ if (remindUntil && remindUntil > nowMs) return null;
1723
+ return {
1724
+ current_version: currentVersion,
1725
+ latest_version: latestVersion,
1726
+ mcpb_url: mcpbUrl,
1727
+ release_url: releaseUrl
1728
+ };
1729
+ }
1730
+ async function recordRunningVersion(currentVersion, stateStore, telemetry) {
1731
+ const cur = await stateStore.read();
1732
+ const prev = cur.previous_running_version;
1733
+ if (prev === currentVersion) return;
1734
+ if (prev && compareSemver(currentVersion, prev) > 0) {
1735
+ telemetry.captureVersionUpdated?.({
1736
+ from_version: prev,
1737
+ to_version: currentVersion
1738
+ });
1739
+ }
1740
+ await stateStore.update((s) => ({
1741
+ ...s,
1742
+ previous_running_version: currentVersion,
1743
+ // Clear any prior "remind me tomorrow" for the version we just landed on —
1744
+ // the user has effectively answered the prompt by upgrading.
1745
+ remind_until: void 0,
1746
+ // Drop suppression of versions we've now surpassed.
1747
+ suppressed_versions: s.suppressed_versions.filter(
1748
+ (v) => compareSemver(v, currentVersion) > 0
1749
+ )
1750
+ }));
1751
+ }
1752
+
1753
+ // src/update-tool.ts
1754
+ var TWENTY_FOUR_HOURS_MS = 24 * 60 * 60 * 1e3;
1755
+ var DESCRIPTION = "Record the user's choice on an update prompt surfaced via `update_available` on leadbay_account_status. Pass `action: 'install' | 'remind_tomorrow' | 'skip'` and `version` (the `latest_version` from the prompt). On 'install', the server returns `{ mcpb_url, release_url }` \u2014 show the user a clickable link to mcpb_url so Claude Desktop's native installer opens it. On 'remind_tomorrow' the server suppresses the prompt for 24 hours. On 'skip' the version is suppressed permanently. Call this tool EXACTLY ONCE per prompt \u2014 do not loop, and do not call it speculatively when no update_available block is present.";
1756
+ function buildAcknowledgeUpdateTool(opts) {
1757
+ const now = opts.now ?? Date.now;
1758
+ return {
1759
+ name: "leadbay_acknowledge_update",
1760
+ annotations: {
1761
+ title: "Acknowledge a Leadbay MCP update prompt",
1762
+ readOnlyHint: false,
1763
+ destructiveHint: false,
1764
+ idempotentHint: false,
1765
+ openWorldHint: false
1766
+ },
1767
+ description: DESCRIPTION,
1768
+ inputSchema: {
1769
+ type: "object",
1770
+ properties: {
1771
+ action: {
1772
+ type: "string",
1773
+ enum: ["install", "remind_tomorrow", "skip"],
1774
+ description: "What the user chose: 'install' (they'll click the link), 'remind_tomorrow' (suppress for 24h), or 'skip' (suppress this version permanently)."
1775
+ },
1776
+ version: {
1777
+ type: "string",
1778
+ description: "The latest_version string from the update_available block. Used for suppression and event correlation."
1779
+ }
1780
+ },
1781
+ required: ["action", "version"],
1782
+ additionalProperties: false
1783
+ },
1784
+ outputSchema: {
1785
+ type: "object",
1786
+ properties: {
1787
+ ok: { type: "boolean" },
1788
+ action: { type: "string" },
1789
+ version: { type: "string" },
1790
+ message: { type: "string" },
1791
+ mcpb_url: { type: ["string", "null"] },
1792
+ release_url: { type: ["string", "null"] }
1793
+ },
1794
+ required: ["ok", "action", "version", "message"]
1795
+ },
1796
+ execute: async (_client, args, _ctx) => {
1797
+ const action = String(args?.action ?? "");
1798
+ const version = String(args?.version ?? "");
1799
+ if (action !== "install" && action !== "remind_tomorrow" && action !== "skip") {
1800
+ return {
1801
+ error: true,
1802
+ code: "INVALID_ARGUMENT",
1803
+ message: `Unknown action '${action}' for leadbay_acknowledge_update.`,
1804
+ hint: "Pass one of: 'install', 'remind_tomorrow', 'skip'."
1805
+ };
1806
+ }
1807
+ if (!version) {
1808
+ return {
1809
+ error: true,
1810
+ code: "INVALID_ARGUMENT",
1811
+ message: "Missing required `version` argument.",
1812
+ hint: "Pass the latest_version string from the update_available block."
1813
+ };
1814
+ }
1815
+ if (action === "install") {
1816
+ const state = await opts.stateStore.read();
1817
+ opts.telemetry.captureUpdateInstallClicked?.({
1818
+ current_version: opts.currentVersion,
1819
+ latest_version: version
1820
+ });
1821
+ return {
1822
+ ok: true,
1823
+ action,
1824
+ version,
1825
+ mcpb_url: state.latest_known_mcpb_url ?? null,
1826
+ release_url: state.latest_known_release_url ?? null,
1827
+ message: state.latest_known_mcpb_url ? "Show the user the mcpb_url as a clickable link \u2014 opening it in Claude Desktop runs the native installer." : "No .mcpb URL is cached. Direct the user to the release_url to download manually."
1828
+ };
1829
+ }
1830
+ if (action === "remind_tomorrow") {
1831
+ await opts.stateStore.update((cur) => ({
1832
+ ...cur,
1833
+ remind_until: now() + TWENTY_FOUR_HOURS_MS
1834
+ }));
1835
+ opts.telemetry.captureUpdateDismissed?.({
1836
+ current_version: opts.currentVersion,
1837
+ latest_version: version,
1838
+ action: "remind_tomorrow"
1839
+ });
1840
+ return {
1841
+ ok: true,
1842
+ action,
1843
+ version,
1844
+ message: "Reminder snoozed for 24 hours. No further prompts will appear in that window."
1845
+ };
1846
+ }
1847
+ await opts.stateStore.update((cur) => ({
1848
+ ...cur,
1849
+ suppressed_versions: cur.suppressed_versions.includes(version) ? cur.suppressed_versions : [...cur.suppressed_versions, version]
1850
+ }));
1851
+ opts.telemetry.captureUpdateDismissed?.({
1852
+ current_version: opts.currentVersion,
1853
+ latest_version: version,
1854
+ action: "skip"
1855
+ });
1856
+ return {
1857
+ ok: true,
1858
+ action,
1859
+ version,
1860
+ message: `Version ${version} suppressed. Future releases will still prompt.`
1861
+ };
1862
+ }
1863
+ };
1864
+ }
1865
+
1230
1866
  // src/server.ts
1231
1867
  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.";
1232
1868
  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.";
@@ -1249,6 +1885,10 @@ function buildStartHereParagraph(has) {
1249
1885
  }
1250
1886
  return base + " When the user asks for refinement, contact enrichment, audience changes, or outreach reporting, tell them: those actions require write tools, currently disabled. Re-enable by removing `LEADBAY_MCP_WRITE=0` from your MCP client config and restarting the client. Also: do not promise to log outreach \u2014 the report_outreach tool is not available in this configuration.";
1251
1887
  }
1888
+ function buildUpdateAvailableParagraph(has) {
1889
+ if (!has("leadbay_acknowledge_update")) return null;
1890
+ return "MCP auto-update: when `leadbay_account_status` returns an `update_available` field (`{ current_version, latest_version, mcpb_url, release_url }`), a newer MCP server release is published and the user has NOT suppressed it. Surface a prompt via `ask_user_input_v0` with EXACTLY these three options: \"Install now\", \"Remind me tomorrow\", \"Skip this version\". Map the user's choice to `leadbay_acknowledge_update({ action: 'install' | 'remind_tomorrow' | 'skip', version: latest_version })`. On 'install', the tool returns `mcpb_url` \u2014 render it as a clickable markdown link the user can open in Claude Desktop (the .mcpb extension triggers the native installer). The user does NOT need to restart anything before clicking \u2014 the new server takes effect on the next MCP session. Prompt the user ONCE per session per version \u2014 don't re-prompt within the same chat after they've acknowledged.";
1891
+ }
1252
1892
  function buildRhythmParagraph(has) {
1253
1893
  if (has("leadbay_report_outreach")) {
1254
1894
  return "Suggested 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.";
@@ -1329,6 +1969,8 @@ function buildServerInstructions(exposed) {
1329
1969
  parts.push(buildScoringParagraph(has));
1330
1970
  parts.push(buildStartHereParagraph(has));
1331
1971
  parts.push(buildRhythmParagraph(has));
1972
+ const updateParagraph = buildUpdateAvailableParagraph(has);
1973
+ if (updateParagraph) parts.push(updateParagraph);
1332
1974
  const promptsCatalog = buildPromptsCatalogParagraph(has);
1333
1975
  if (promptsCatalog) parts.push(promptsCatalog);
1334
1976
  parts.push(RESOURCES_PARAGRAPH);
@@ -1376,6 +2018,16 @@ function buildServer(client, opts = {}) {
1376
2018
  exposedTools.push(...granularWriteTools);
1377
2019
  }
1378
2020
  }
2021
+ if (opts.updateStateStore) {
2022
+ exposedTools.push(
2023
+ buildAcknowledgeUpdateTool({
2024
+ stateStore: opts.updateStateStore,
2025
+ telemetry: opts.telemetry ?? NOOP_TELEMETRY,
2026
+ currentVersion: opts.version ?? "0.0.0-dev",
2027
+ logger: opts.logger
2028
+ })
2029
+ );
2030
+ }
1379
2031
  if (opts.extraTools) {
1380
2032
  exposedTools.push(...opts.extraTools);
1381
2033
  }
@@ -1465,10 +2117,45 @@ function buildServer(client, opts = {}) {
1465
2117
  const DEBUG_RAW = process.env.LEADBAY_DEBUG ?? "";
1466
2118
  const DEBUG_ON = DEBUG_RAW === "1" || DEBUG_RAW.toLowerCase() === "true";
1467
2119
  const telemetry = opts.telemetry ?? NOOP_TELEMETRY;
2120
+ const promptedVersionsThisSession = /* @__PURE__ */ new Set();
2121
+ const serverVersion = opts.version ?? "0.0.0-dev";
2122
+ const UPDATE_CHECK_DISABLED = process.env.LEADBAY_UPDATE_CHECK_DISABLED === "1";
2123
+ const maybeRefreshUpdate = () => {
2124
+ if (UPDATE_CHECK_DISABLED) return;
2125
+ if (!opts.updateStateStore) return;
2126
+ void checkForUpdate({
2127
+ currentVersion: serverVersion,
2128
+ stateStore: opts.updateStateStore,
2129
+ telemetry,
2130
+ logger: opts.logger
2131
+ }).catch((err) => {
2132
+ opts.logger?.warn?.(
2133
+ `update_check.unexpected ${err?.message ?? err}`
2134
+ );
2135
+ });
2136
+ };
2137
+ const maybeAttachUpdate = (toolName, result) => {
2138
+ if (toolName !== "leadbay_account_status") return;
2139
+ if (!opts.updateStateStore) return;
2140
+ if (result === null || typeof result !== "object" || Array.isArray(result)) {
2141
+ return;
2142
+ }
2143
+ const info = getCachedUpdateInfo();
2144
+ if (!info) return;
2145
+ result.update_available = info;
2146
+ if (!promptedVersionsThisSession.has(info.latest_version)) {
2147
+ promptedVersionsThisSession.add(info.latest_version);
2148
+ telemetry.captureUpdatePrompted?.({
2149
+ current_version: serverVersion,
2150
+ latest_version: info.latest_version
2151
+ });
2152
+ }
2153
+ };
1468
2154
  const isLeadbayBusinessError = (err) => err != null && typeof err === "object" && err.error === true && typeof err.code === "string";
1469
2155
  server.setRequestHandler(CallToolRequestSchema, async (req, extra) => {
1470
2156
  const callStart = Date.now();
1471
2157
  const name = req.params.name;
2158
+ maybeRefreshUpdate();
1472
2159
  const tool = toolByName.get(name);
1473
2160
  if (!tool) {
1474
2161
  return {
@@ -1522,6 +2209,7 @@ function buildServer(client, opts = {}) {
1522
2209
  progress,
1523
2210
  elicit
1524
2211
  });
2212
+ maybeAttachUpdate(name, result);
1525
2213
  if (result && typeof result === "object" && result.error === true) {
1526
2214
  const envText = formatErrorForLLM(result);
1527
2215
  const envDur = Date.now() - callStart;
@@ -1659,9 +2347,202 @@ function buildServer(client, opts = {}) {
1659
2347
  return server;
1660
2348
  }
1661
2349
 
2350
+ // src/update-state.ts
2351
+ import {
2352
+ mkdir as mkdirAsync,
2353
+ lstat,
2354
+ open as fsOpen,
2355
+ readFile,
2356
+ rename,
2357
+ stat,
2358
+ unlink
2359
+ } from "fs/promises";
2360
+ import { constants as fsConstants } from "fs";
2361
+ import { dirname, resolve as resolvePath } from "path";
2362
+ import { homedir } from "os";
2363
+ function emptyState() {
2364
+ return {
2365
+ last_check_time: 0,
2366
+ suppressed_versions: []
2367
+ };
2368
+ }
2369
+ var UpdateStateStore = class {
2370
+ backend;
2371
+ path;
2372
+ logger;
2373
+ allowUnsafePath;
2374
+ now;
2375
+ memory = emptyState();
2376
+ initialized = false;
2377
+ constructor(opts) {
2378
+ this.backend = opts.backend;
2379
+ this.logger = opts.logger;
2380
+ this.allowUnsafePath = !!opts.allowUnsafePath;
2381
+ this.now = opts.now ?? Date.now;
2382
+ if (this.backend === "file") {
2383
+ if (!opts.path) {
2384
+ throw new Error("UpdateStateStore: path is required when backend=file");
2385
+ }
2386
+ this.path = resolvePath(opts.path);
2387
+ this.validatePath(this.path);
2388
+ }
2389
+ }
2390
+ get durability() {
2391
+ return this.backend;
2392
+ }
2393
+ get resolvedPath() {
2394
+ return this.path;
2395
+ }
2396
+ validatePath(p) {
2397
+ if (this.allowUnsafePath) return;
2398
+ const home = resolvePath(homedir());
2399
+ if (p !== home && !p.startsWith(home + "/") && !p.startsWith(home + "\\")) {
2400
+ throw new Error(
2401
+ `UpdateStateStore: path ${p} is outside $HOME (${home}). Set LEADBAY_UPDATE_STATE_PATH_UNSAFE=1 to override.`
2402
+ );
2403
+ }
2404
+ }
2405
+ async ensureInitialized() {
2406
+ if (this.initialized || this.backend !== "file") {
2407
+ this.initialized = true;
2408
+ return;
2409
+ }
2410
+ const dir = dirname(this.path);
2411
+ await mkdirAsync(dir, { recursive: true, mode: 448 });
2412
+ try {
2413
+ const st = await lstat(this.path);
2414
+ if (st.isSymbolicLink()) {
2415
+ throw new Error(
2416
+ `UpdateStateStore: refusing to use ${this.path} \u2014 path is a symlink. Set LEADBAY_UPDATE_STATE_PATH_UNSAFE=1 to override.`
2417
+ );
2418
+ }
2419
+ } catch (err) {
2420
+ if (err?.code !== "ENOENT") throw err;
2421
+ }
2422
+ this.initialized = true;
2423
+ }
2424
+ async read() {
2425
+ if (this.backend === "memory") return { ...this.memory, suppressed_versions: [...this.memory.suppressed_versions] };
2426
+ await this.ensureInitialized();
2427
+ let raw;
2428
+ try {
2429
+ raw = await readFile(this.path, "utf8");
2430
+ } catch (err) {
2431
+ if (err?.code === "ENOENT") return emptyState();
2432
+ throw err;
2433
+ }
2434
+ try {
2435
+ const parsed = JSON.parse(raw);
2436
+ return this.validate(parsed);
2437
+ } catch (err) {
2438
+ this.logger?.warn?.(
2439
+ `update_state.parse_failed ${err?.message ?? err}; resetting to empty`
2440
+ );
2441
+ return emptyState();
2442
+ }
2443
+ }
2444
+ async write(state) {
2445
+ if (this.backend === "memory") {
2446
+ this.memory = { ...state, suppressed_versions: [...state.suppressed_versions] };
2447
+ return;
2448
+ }
2449
+ await this.ensureInitialized();
2450
+ const tmp = `${this.path}.tmp.${process.pid}.${this.now()}`;
2451
+ const handle = await openTmpFileExclusive(tmp);
2452
+ try {
2453
+ await handle.writeFile(JSON.stringify(state, null, 2));
2454
+ } finally {
2455
+ await handle.close();
2456
+ }
2457
+ await rename(tmp, this.path);
2458
+ }
2459
+ /**
2460
+ * Apply a partial mutation atomically (read → merge → write). Caller
2461
+ * passes a function so concurrent mutators (the startup check + a
2462
+ * tool call landing during it) compose without dropping fields.
2463
+ */
2464
+ async update(mutator) {
2465
+ const cur = await this.read();
2466
+ const next = mutator(cur);
2467
+ await this.write(next);
2468
+ return next;
2469
+ }
2470
+ validate(raw) {
2471
+ if (!raw || typeof raw !== "object") return emptyState();
2472
+ const r = raw;
2473
+ const out = emptyState();
2474
+ if (typeof r.last_check_time === "number" && Number.isFinite(r.last_check_time)) {
2475
+ out.last_check_time = r.last_check_time;
2476
+ }
2477
+ if (typeof r.latest_known_version === "string") {
2478
+ out.latest_known_version = r.latest_known_version;
2479
+ }
2480
+ if (typeof r.latest_known_mcpb_url === "string") {
2481
+ out.latest_known_mcpb_url = r.latest_known_mcpb_url;
2482
+ }
2483
+ if (typeof r.latest_known_release_url === "string") {
2484
+ out.latest_known_release_url = r.latest_known_release_url;
2485
+ }
2486
+ if (typeof r.etag === "string") out.etag = r.etag;
2487
+ if (Array.isArray(r.suppressed_versions)) {
2488
+ out.suppressed_versions = r.suppressed_versions.filter(
2489
+ (v) => typeof v === "string"
2490
+ );
2491
+ }
2492
+ if (typeof r.remind_until === "number" && Number.isFinite(r.remind_until)) {
2493
+ out.remind_until = r.remind_until;
2494
+ }
2495
+ if (typeof r.previous_running_version === "string") {
2496
+ out.previous_running_version = r.previous_running_version;
2497
+ }
2498
+ return out;
2499
+ }
2500
+ };
2501
+ async function openTmpFileExclusive(path) {
2502
+ try {
2503
+ return await fsOpen(
2504
+ path,
2505
+ fsConstants.O_CREAT | fsConstants.O_WRONLY | fsConstants.O_EXCL,
2506
+ 384
2507
+ );
2508
+ } catch (err) {
2509
+ if (err?.code === "EEXIST") {
2510
+ await unlink(path).catch(() => {
2511
+ });
2512
+ return fsOpen(
2513
+ path,
2514
+ fsConstants.O_CREAT | fsConstants.O_WRONLY | fsConstants.O_EXCL,
2515
+ 384
2516
+ );
2517
+ }
2518
+ throw err;
2519
+ }
2520
+ }
2521
+ async function createDefaultUpdateStateStore(opts = {}) {
2522
+ const env = opts.env ?? process.env;
2523
+ const allowUnsafePath = env.LEADBAY_UPDATE_STATE_PATH_UNSAFE === "1";
2524
+ const path = env.LEADBAY_UPDATE_STATE_PATH ?? resolvePath(homedir(), ".leadbay", "update-state.json");
2525
+ try {
2526
+ const store = new UpdateStateStore({
2527
+ backend: "file",
2528
+ path,
2529
+ logger: opts.logger,
2530
+ allowUnsafePath
2531
+ });
2532
+ await store.ensureInitialized();
2533
+ await stat(dirname(path));
2534
+ return store;
2535
+ } catch (err) {
2536
+ opts.logger?.warn?.(
2537
+ `update_state.fallback_memory path=${path} reason=${err?.message ?? err}`
2538
+ );
2539
+ return new UpdateStateStore({ backend: "memory", logger: opts.logger });
2540
+ }
2541
+ }
2542
+
1662
2543
  // src/bin.ts
1663
2544
  import { createRequire } from "module";
1664
- var VERSION = "0.10.1";
2545
+ var VERSION = "0.12.0";
1665
2546
  var HELP = `
1666
2547
  leadbay-mcp ${VERSION} \u2014 Leadbay Model Context Protocol server
1667
2548
 
@@ -1710,7 +2591,7 @@ EXAMPLE Claude Desktop config (~/Library/Application Support/Claude/claude_deskt
1710
2591
  "mcpServers": {
1711
2592
  "leadbay": {
1712
2593
  "command": "npx",
1713
- "args": ["-y", "@leadbay/mcp@0.10"],
2594
+ "args": ["-y", "@leadbay/mcp@0.12"],
1714
2595
  "env": {
1715
2596
  "LEADBAY_TOKEN": "lb_...",
1716
2597
  "LEADBAY_REGION": "us",
@@ -1763,9 +2644,47 @@ function exitWithTokenError() {
1763
2644
  );
1764
2645
  process.exit(1);
1765
2646
  }
2647
+ var BrokenLeadbayClient = class extends LeadbayClient {
2648
+ stubError;
2649
+ constructor(stubError, baseUrl, region) {
2650
+ super(baseUrl, "broken-token-startup-auth-failure", region);
2651
+ this.stubError = stubError;
2652
+ }
2653
+ async request() {
2654
+ throw this.stubError;
2655
+ }
2656
+ async requestVoid() {
2657
+ throw this.stubError;
2658
+ }
2659
+ async requestRawBinary() {
2660
+ throw this.stubError;
2661
+ }
2662
+ };
2663
+ function makeBrokenClient(stubError, region) {
2664
+ const baseUrl = region === "fr" ? "https://api-fr.leadbay.app" : "https://api-us.leadbay.app";
2665
+ return new BrokenLeadbayClient(stubError, baseUrl, region);
2666
+ }
1766
2667
  async function resolveClientFromEnv(logger) {
1767
2668
  const token = process.env.LEADBAY_TOKEN;
1768
- if (!token) exitWithTokenError();
2669
+ if (!token) {
2670
+ process.stderr.write(
2671
+ "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"
2672
+ );
2673
+ const regionEnv2 = process.env.LEADBAY_REGION;
2674
+ const region = regionEnv2 === "fr" ? "fr" : "us";
2675
+ return {
2676
+ client: makeBrokenClient(
2677
+ {
2678
+ error: true,
2679
+ code: "AUTH_MISSING",
2680
+ message: "LEADBAY_TOKEN environment variable is not set.",
2681
+ hint: "Run `npx -y @leadbay/mcp install --email <you> --region <us|fr>` to mint a token, then set LEADBAY_TOKEN in your MCP client config."
2682
+ },
2683
+ region
2684
+ ),
2685
+ authState: "missing"
2686
+ };
2687
+ }
1769
2688
  const regionEnv = process.env.LEADBAY_REGION;
1770
2689
  const explicitRegion = regionEnv === "us" || regionEnv === "fr" ? regionEnv : void 0;
1771
2690
  const baseUrl = process.env.LEADBAY_BASE_URL;
@@ -1773,7 +2692,7 @@ async function resolveClientFromEnv(logger) {
1773
2692
  const config = { token };
1774
2693
  if (baseUrl) config.baseUrl = baseUrl;
1775
2694
  if (explicitRegion) config.region = explicitRegion;
1776
- return createClient(config);
2695
+ return { client: createClient(config), authState: "ok" };
1777
2696
  }
1778
2697
  process.stderr.write(
1779
2698
  "[leadbay-mcp warn] LEADBAY_REGION is unset; probing api-us and api-fr in parallel.\n Your bearer token will be sent to BOTH backends. Set LEADBAY_REGION=us|fr in your\n MCP client config to avoid this.\n"
@@ -1785,7 +2704,8 @@ async function resolveClientFromEnv(logger) {
1785
2704
  return c;
1786
2705
  };
1787
2706
  try {
1788
- return await Promise.any([probe("us"), probe("fr")]);
2707
+ const client = await Promise.any([probe("us"), probe("fr")]);
2708
+ return { client, authState: "ok" };
1789
2709
  } catch (err) {
1790
2710
  const errors = err?.errors ?? [];
1791
2711
  const firstAuth = errors.find(
@@ -1797,14 +2717,28 @@ async function resolveClientFromEnv(logger) {
1797
2717
  Tip: verify your LEADBAY_TOKEN is valid and, if you know your region, set LEADBAY_REGION=us or LEADBAY_REGION=fr.
1798
2718
  `
1799
2719
  );
1800
- process.exit(1);
2720
+ return {
2721
+ client: makeBrokenClient(
2722
+ {
2723
+ error: true,
2724
+ code: firstAuth.code,
2725
+ message: firstAuth.message,
2726
+ hint: "Verify your LEADBAY_TOKEN is valid. If you know your region, set LEADBAY_REGION=us or LEADBAY_REGION=fr to skip auto-probing. Mint a fresh token with `leadbay-mcp login --email <you> --region <us|fr>`."
2727
+ },
2728
+ "us"
2729
+ ),
2730
+ authState: "expired"
2731
+ };
1801
2732
  }
1802
2733
  const firstMsg = errors[0]?.message ?? String(err);
1803
2734
  process.stderr.write(
1804
2735
  `leadbay-mcp: region auto-detection failed (${firstMsg}). Defaulting to us; set LEADBAY_REGION to skip probing.
1805
2736
  `
1806
2737
  );
1807
- return createClient({ token, region: "us" });
2738
+ return {
2739
+ client: createClient({ token, region: "us" }),
2740
+ authState: "probe_failed"
2741
+ };
1808
2742
  }
1809
2743
  }
1810
2744
  async function readPassword() {
@@ -1958,7 +2892,7 @@ async function runLogin(args) {
1958
2892
  let result;
1959
2893
  try {
1960
2894
  if (pinnedRegion && !allowFallback) {
1961
- const { REGIONS } = await import("./dist-BHLIJAIH.js");
2895
+ const { REGIONS } = await import("./dist-YYVFSDMH.js");
1962
2896
  const baseUrl = REGIONS[pinnedRegion];
1963
2897
  const c = createClient({ region: pinnedRegion });
1964
2898
  const token = await loginAt(baseUrl, email, password);
@@ -1977,7 +2911,7 @@ async function runLogin(args) {
1977
2911
  mcpServers: {
1978
2912
  leadbay: {
1979
2913
  command: "npx",
1980
- args: ["-y", "@leadbay/mcp@0.10"],
2914
+ args: ["-y", "@leadbay/mcp@0.12"],
1981
2915
  env: {
1982
2916
  LEADBAY_TOKEN: result.token,
1983
2917
  LEADBAY_REGION: result.region
@@ -2017,7 +2951,7 @@ Or for Claude Code (token included \u2014 same warning applies):
2017
2951
  claude mcp add leadbay --scope user \\
2018
2952
  --env LEADBAY_TOKEN=${result.token} \\
2019
2953
  --env LEADBAY_REGION=${result.region} \\
2020
- -- npx -y @leadbay/mcp@0.10
2954
+ -- npx -y @leadbay/mcp@0.12
2021
2955
 
2022
2956
  Restart your MCP client to pick up the new server.
2023
2957
  `
@@ -2064,8 +2998,8 @@ Restart your MCP client to pick up the new server.
2064
2998
  let actualMode;
2065
2999
  try {
2066
3000
  const { writeFileSync, chmodSync, mkdirSync, renameSync, statSync, unlinkSync } = await import("fs");
2067
- const { dirname } = await import("path");
2068
- mkdirSync(dirname(targetPath), { recursive: true });
3001
+ const { dirname: dirname2 } = await import("path");
3002
+ mkdirSync(dirname2(targetPath), { recursive: true });
2069
3003
  const tmp = targetPath + ".tmp." + process.pid;
2070
3004
  writeFileSync(tmp, JSON.stringify(config, null, 2) + "\n", {
2071
3005
  encoding: "utf8",
@@ -2123,7 +3057,7 @@ For Claude Code, run:
2123
3057
  claude mcp add leadbay --scope user \\
2124
3058
  --env LEADBAY_TOKEN=$(jq -r .mcpServers.leadbay.env.LEADBAY_TOKEN ${quotedPath}) \\
2125
3059
  --env LEADBAY_REGION=${result.region} \\
2126
- -- npx -y @leadbay/mcp@0.10
3060
+ -- npx -y @leadbay/mcp@0.12
2127
3061
  `
2128
3062
  );
2129
3063
  }
@@ -2303,7 +3237,7 @@ function buildClaudeCodeAddArgs(token, region, includeWrite, telemetryEnabled) {
2303
3237
  `LEADBAY_TELEMETRY_ENABLED=${telemetryEnabled ? "true" : "false"}`
2304
3238
  ];
2305
3239
  if (!includeWrite) args.push("--env", `LEADBAY_MCP_WRITE=0`);
2306
- args.push("--", "npx", "-y", "@leadbay/mcp@0.10");
3240
+ args.push("--", "npx", "-y", "@leadbay/mcp@0.12");
2307
3241
  return args;
2308
3242
  }
2309
3243
  async function installInClaudeCode(token, region, includeWrite, telemetryEnabled) {
@@ -2329,7 +3263,7 @@ async function installInClaudeCode(token, region, includeWrite, telemetryEnabled
2329
3263
  async function installInJsonConfig(configPath, token, region, includeWrite, telemetryEnabled) {
2330
3264
  try {
2331
3265
  const { readFileSync, writeFileSync, existsSync, mkdirSync, statSync } = await import("fs");
2332
- const { dirname } = await import("path");
3266
+ const { dirname: dirname2 } = await import("path");
2333
3267
  let parsed = {};
2334
3268
  let preserved = {};
2335
3269
  if (existsSync(configPath)) {
@@ -2341,7 +3275,7 @@ async function installInJsonConfig(configPath, token, region, includeWrite, tele
2341
3275
  return { ok: false, message: `existing ${configPath} is not valid JSON; refusing to overwrite` };
2342
3276
  }
2343
3277
  } else {
2344
- mkdirSync(dirname(configPath), { recursive: true });
3278
+ mkdirSync(dirname2(configPath), { recursive: true });
2345
3279
  }
2346
3280
  parsed.mcpServers = parsed.mcpServers ?? {};
2347
3281
  const env = {
@@ -2353,7 +3287,7 @@ async function installInJsonConfig(configPath, token, region, includeWrite, tele
2353
3287
  if (!includeWrite) env.LEADBAY_MCP_WRITE = "0";
2354
3288
  parsed.mcpServers.leadbay = {
2355
3289
  command: "npx",
2356
- args: ["-y", "@leadbay/mcp@0.10"],
3290
+ args: ["-y", "@leadbay/mcp@0.12"],
2357
3291
  env
2358
3292
  };
2359
3293
  const tmp = configPath + ".tmp";
@@ -2457,7 +3391,7 @@ leadbay-mcp install \u2014 detected MCP clients on this machine:
2457
3391
  let region;
2458
3392
  try {
2459
3393
  if (pinnedRegion && !allowFallback) {
2460
- const { REGIONS } = await import("./dist-BHLIJAIH.js");
3394
+ const { REGIONS } = await import("./dist-YYVFSDMH.js");
2461
3395
  const baseUrl = REGIONS[pinnedRegion];
2462
3396
  token = await loginAt(baseUrl, email, password);
2463
3397
  region = pinnedRegion;
@@ -2530,7 +3464,7 @@ leadbay-mcp install \u2014 detected MCP clients on this machine:
2530
3464
  process.stderr.write(
2531
3465
  `
2532
3466
  The token was written into client config files but never printed to your terminal.
2533
- Verify with: LEADBAY_TOKEN=$(...) npx -y @leadbay/mcp@0.10 doctor
3467
+ Verify with: LEADBAY_TOKEN=$(...) npx -y @leadbay/mcp@0.12 doctor
2534
3468
  Restart your MCP client(s) to pick up the new server.
2535
3469
  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).
2536
3470
  `
@@ -2581,6 +3515,26 @@ async function runDoctor() {
2581
3515
  );
2582
3516
  return 1;
2583
3517
  }
3518
+ var startupSafetyNetsInstalled = false;
3519
+ function installStartupSafetyNets(logger) {
3520
+ if (startupSafetyNetsInstalled) return;
3521
+ startupSafetyNetsInstalled = true;
3522
+ const reportAndExit = (label, err) => {
3523
+ const msg = err instanceof Error ? err.stack ?? err.message : String(err);
3524
+ process.stderr.write(`leadbay-mcp: ${label}: ${msg}
3525
+ `);
3526
+ logger.error?.(`${label}: ${msg}`);
3527
+ try {
3528
+ const bootTelemetry = initTelemetry({ version: VERSION });
3529
+ bootTelemetry.captureException(err, { tool: "__startup__" });
3530
+ void bootTelemetry.shutdown();
3531
+ } catch {
3532
+ }
3533
+ process.exit(1);
3534
+ };
3535
+ process.on("uncaughtException", (err) => reportAndExit("uncaughtException", err));
3536
+ process.on("unhandledRejection", (err) => reportAndExit("unhandledRejection", err));
3537
+ }
2584
3538
  async function main() {
2585
3539
  const arg = process.argv[2];
2586
3540
  if (arg === "--version" || arg === "-v") {
@@ -2603,23 +3557,45 @@ async function main() {
2603
3557
  process.exit(await runDoctor());
2604
3558
  }
2605
3559
  const logger = makeStderrLogger(parseLogLevel(process.env.LEADBAY_LOG_LEVEL));
3560
+ installStartupSafetyNets(logger);
2606
3561
  const telemetry = initTelemetry({ version: VERSION, logger });
2607
- const client = await resolveClientFromEnv(logger);
3562
+ const { client, authState } = await resolveClientFromEnv(logger);
2608
3563
  telemetry.identify(client);
3564
+ telemetry.captureStartup({
3565
+ auth_state: authState,
3566
+ region: client.region
3567
+ });
2609
3568
  const includeAdvanced = process.env.LEADBAY_MCP_ADVANCED === "1";
2610
3569
  const includeWrite = parseWriteEnv();
2611
3570
  const bulkTracker = await createDefaultBulkStore({ logger });
3571
+ const updateStateStore = await createDefaultUpdateStateStore({ logger });
3572
+ void recordRunningVersion(VERSION, updateStateStore, telemetry).catch((err) => {
3573
+ logger.warn?.(
3574
+ `update_state.record_version_failed ${err?.message ?? err}`
3575
+ );
3576
+ });
3577
+ if (process.env.LEADBAY_UPDATE_CHECK_DISABLED !== "1") {
3578
+ void checkForUpdate({
3579
+ currentVersion: VERSION,
3580
+ stateStore: updateStateStore,
3581
+ telemetry,
3582
+ logger
3583
+ }).catch((err) => {
3584
+ logger.warn?.(`update_check.unexpected ${err?.message ?? err}`);
3585
+ });
3586
+ }
2612
3587
  const server = buildServer(client, {
2613
3588
  includeAdvanced,
2614
3589
  includeWrite,
2615
3590
  logger,
2616
3591
  bulkTracker,
2617
3592
  version: VERSION,
2618
- telemetry
3593
+ telemetry,
3594
+ updateStateStore
2619
3595
  });
2620
3596
  const transport = new StdioServerTransport();
2621
3597
  logger.info?.(
2622
- `Starting MCP server v${VERSION} (advanced=${includeAdvanced}, write=${includeWrite}, baseUrl=${client.baseUrl}, bulk_store=${bulkTracker.durability})`
3598
+ `Starting MCP server v${VERSION} (advanced=${includeAdvanced}, write=${includeWrite}, baseUrl=${client.baseUrl}, bulk_store=${bulkTracker.durability}, auth_state=${authState})`
2623
3599
  );
2624
3600
  await server.connect(transport);
2625
3601
  const shutdown = async (code) => {
@@ -2661,6 +3637,7 @@ export {
2661
3637
  checkLoginCollision,
2662
3638
  computeFreshDefaultPath,
2663
3639
  detectClaudeDesktopMode,
3640
+ makeBrokenClient,
2664
3641
  parseWriteEnv,
2665
3642
  resolveClientFromEnv,
2666
3643
  resolveDefaultCredentialsPath