@leadbay/mcp 0.32.4 → 0.32.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,61 @@
1
1
  # Changelog — @leadbay/mcp
2
2
 
3
+ ## 0.32.6 — 2026-09-01
4
+
5
+ The 401 auto-retry is GET-only — replaying a write could double-execute a
6
+ mutation that already committed — but `mapErrorResponse` wrote its hint as if
7
+ `request()` had always retried. On `leadbay_create_topup_link` (a POST) the
8
+ agent was told the call had already been retried when it had been attempted
9
+ exactly once. Covers acceptance criteria 4 and 5 of product#3998; the 401 itself
10
+ is a backend bug fixed in leadbay/backend#1989.
11
+
12
+ - The GET-only rule is extracted into `retriesOn401` so the retry path and the
13
+ error mapper read one source of truth, and the actual outcome is threaded into
14
+ `mapErrorResponse` rather than assumed.
15
+ - The not-retried hint states only the fact and never the reason. A GET with
16
+ `retryOn401:false` (the startup auth probe) is not a write, and the earlier
17
+ text told it that it was. Why the retry didn't run is not actionable for the
18
+ agent — only that this was attempt one — so the flag stays a boolean.
19
+ - `readOnlyHint: false` on both Stripe tools. Each mints a Stripe session, and
20
+ for an org with no customer yet the shared `getStripeCustomer` path creates
21
+ the customer and persists `organizations.stripe_customer_id`.
22
+ - The two Stripe tools stay in `granularReadTools`. `readOnlyHint` (a client
23
+ confirmation hint) and `includeWrite` (a capability gate over the user's
24
+ Leadbay data) are different axes, and this repo already treats them that way
25
+ — `leadbay_preview_bulk_enrichment` sits in `granularWriteTools` with
26
+ `readOnlyHint: true`. Moving them would leave a `--no-write` user who hits
27
+ quota with no top-up link at all, which is the exact failure product#3998 was
28
+ filed for.
29
+
30
+ ## 0.32.5 — 2026-09-01
31
+
32
+ `leadbay_update_contact` returned `NOT_FOUND` / 404 on 100% of the calls ever
33
+ made to it (product#3997). Not a missing route and not a backend bug.
34
+
35
+ Leadbay holds contacts in two id namespaces — `org_contacts` (the org's own
36
+ directory) and `paid_contacts` (enrichment results) — with separate models,
37
+ separate DAOs and separate ids. `POST /contacts/{id}/update` resolves
38
+ `orgContacts.findById` only, so a paid id can only ever 404.
39
+
40
+ `research_lead_by_id` merges `/leads/{id}/enrich/contacts` and
41
+ `/leads/{id}/contacts` into `contacts.reachable` / `contacts.candidates`, split
42
+ by **whether the person is messagable right now, not by which endpoint they came
43
+ from**. That split is right for outreach and wrong for identity: an enriched
44
+ paid contact sits in the same list as an org contact, and the agent had nothing
45
+ to distinguish them by. Roughly half the ids we handed out were unusable by a
46
+ tool the server instructions tell the agent to call after every outreach.
47
+
48
+ - The `source` field (`"org"` / `"paid"`) was already on the wire but undeclared
49
+ and unexplained. It is now documented in `research_lead_by_id`'s output schema
50
+ as the thing that decides whether an id can be passed to
51
+ `leadbay_update_contact`.
52
+ - `leadbay_update_contact`'s description states which namespace it accepts,
53
+ what a 404 means, and routes a correction to a paid contact through
54
+ `leadbay_add_contact` instead — the enrichment row is a provider's answer and
55
+ is not ours to edit.
56
+ - New: `research-contact-source-provenance.test.ts`, covering the case the
57
+ reachability split hides — an enriched paid contact and an org contact in the
58
+ SAME `reachable` list, distinguished only by `source`.
3
59
  ## 0.32.4 — 2026-09-01
4
60
 
5
61
  `leadbay_account_status.notifications` was permanently `[]` on the hosted server
@@ -27,7 +83,6 @@ answer, and the ledger is not called.
27
83
  responses.** Reviving it would mean a `GET /notifications` per tool call to
28
84
  decorate every response. The cost lands on the check-in entry point only,
29
85
  which is where the daily-rhythm channel is actually read.
30
-
31
86
  ## 0.32.0 — 2026-09-01
32
87
 
33
88
  A poll-budget timeout stops being an error (product#4007). The import wizard's
package/dist/bin.js CHANGED
@@ -213,12 +213,13 @@ function parseRetryAfter(value) {
213
213
  }
214
214
  return null;
215
215
  }
216
- var LENS_CACHE_TTL_MS, TASTE_CACHE_TTL_MS, ME_CACHE_TTL_MS, MAX_CONCURRENT, DEFAULT_REQUEST_TIMEOUT_MS, requestSignalStore, REGIONS, API_VERSION, API_PREFIX, _mockFixtures, _mockJournal, LeadbayClient;
216
+ var LENS_CACHE_TTL_MS, TASTE_CACHE_TTL_MS, retriesOn401, ME_CACHE_TTL_MS, MAX_CONCURRENT, DEFAULT_REQUEST_TIMEOUT_MS, requestSignalStore, REGIONS, API_VERSION, API_PREFIX, _mockFixtures, _mockJournal, LeadbayClient;
217
217
  var init_client = __esm({
218
218
  "../core/dist/client.js"() {
219
219
  "use strict";
220
220
  LENS_CACHE_TTL_MS = 5 * 60 * 1e3;
221
221
  TASTE_CACHE_TTL_MS = 10 * 60 * 1e3;
222
+ retriesOn401 = (method) => method.toUpperCase() === "GET";
222
223
  ME_CACHE_TTL_MS = 60 * 1e3;
223
224
  MAX_CONCURRENT = 5;
224
225
  DEFAULT_REQUEST_TIMEOUT_MS = 6e5;
@@ -396,15 +397,16 @@ var init_client = __esm({
396
397
  // error envelope says so.
397
398
  //
398
399
  // Arrow-function field so `this` stays bound even when the method is passed
399
- // as a bare reference (see request()'s ternary). Retries are GET-ONLY: a 401
400
- // on a write (POST/PUT/DELETE) may arrive AFTER the mutation already committed
401
- // server-side, so blindly re-sending it would double-execute the write. Reads
402
- // are idempotent, so retrying them is safe. The 250ms backoff releases the
403
- // concurrency slot first (release → sleep → re-acquire) so a wave of 401s
404
- // doesn't pin all MAX_CONCURRENT slots in setTimeout and stall the queue.
400
+ // as a bare reference (see request()'s ternary). Retries are GET-ONLY (see
401
+ // retriesOn401): a 401 on a write (POST/PUT/DELETE) may arrive AFTER the
402
+ // mutation already committed server-side, so blindly re-sending it would
403
+ // double-execute the write. Reads are idempotent, so retrying them is safe.
404
+ // The 250ms backoff releases the concurrency slot first (release → sleep →
405
+ // re-acquire) so a wave of 401s doesn't pin all MAX_CONCURRENT slots in
406
+ // setTimeout and stall the queue.
405
407
  httpsRequestWithRetry = async (method, url, headers, body, timeoutMs) => {
406
408
  const res = await httpsRequest(method, url, headers, body, timeoutMs);
407
- if (res.status === 401 && method.toUpperCase() === "GET") {
409
+ if (res.status === 401 && retriesOn401(method)) {
408
410
  this.releaseSemaphore();
409
411
  try {
410
412
  await new Promise((r) => setTimeout(r, 250));
@@ -423,6 +425,7 @@ var init_client = __esm({
423
425
  throw this.makeError("NOT_AUTHENTICATED", "Not logged in to Leadbay", "Set LEADBAY_TOKEN in your MCP client config, or run: npx -y -p @leadbay/mcp@latest installer", path);
424
426
  }
425
427
  const retryOn401 = opts?.retryOn401 !== false;
428
+ const retriedOn401 = retryOn401 && retriesOn401(method);
426
429
  await this.acquireSemaphore();
427
430
  try {
428
431
  const url = `${this._baseUrl}${API_PREFIX}${path}`;
@@ -443,7 +446,7 @@ var init_client = __esm({
443
446
  return null;
444
447
  }
445
448
  if (res.status < 200 || res.status >= 300) {
446
- throw this.mapErrorResponse(res.status, res.body, path, res.headers);
449
+ throw this.mapErrorResponse(res.status, res.body, path, res.headers, retriedOn401);
447
450
  }
448
451
  return JSON.parse(res.body);
449
452
  } catch (e) {
@@ -453,6 +456,7 @@ var init_client = __esm({
453
456
  }
454
457
  }
455
458
  async requestVoid(method, path, body) {
459
+ const retriedOn401 = retriesOn401(method);
456
460
  if (process.env.LEADBAY_MOCK === "1") {
457
461
  await this.mockRequest(method, path, body);
458
462
  return;
@@ -477,7 +481,7 @@ var init_client = __esm({
477
481
  retry_after: parseRetryAfter(res.headers["retry-after"])
478
482
  };
479
483
  if (res.status < 200 || res.status >= 300) {
480
- throw this.mapErrorResponse(res.status, res.body, path, res.headers);
484
+ throw this.mapErrorResponse(res.status, res.body, path, res.headers, retriedOn401);
481
485
  }
482
486
  } catch (e) {
483
487
  throw this.mapTransportError(e, `${method} ${path}`);
@@ -491,6 +495,7 @@ var init_client = __esm({
491
495
  // mirror request() exactly. Used by leadbay_import_leads to upload CSVs to
492
496
  // the wizard at POST /1.6/imports.
493
497
  async requestRawBinary(method, path, contentType, body) {
498
+ const retriedOn401 = retriesOn401(method);
494
499
  if (process.env.LEADBAY_MOCK === "1") {
495
500
  return this.mockRequestBinary(method, path, contentType, body);
496
501
  }
@@ -515,7 +520,7 @@ var init_client = __esm({
515
520
  return null;
516
521
  }
517
522
  if (res.status < 200 || res.status >= 300) {
518
- throw this.mapErrorResponse(res.status, res.body, path, res.headers);
523
+ throw this.mapErrorResponse(res.status, res.body, path, res.headers, retriedOn401);
519
524
  }
520
525
  return JSON.parse(res.body);
521
526
  } catch (e) {
@@ -603,7 +608,7 @@ var init_client = __esm({
603
608
  }
604
609
  return envelope;
605
610
  }
606
- mapErrorResponse(status, rawBody, endpoint, headers) {
611
+ mapErrorResponse(status, rawBody, endpoint, headers, retried) {
607
612
  let parsed;
608
613
  try {
609
614
  parsed = JSON.parse(rawBody);
@@ -612,7 +617,7 @@ var init_client = __esm({
612
617
  }
613
618
  const retryAfter = parseRetryAfter(headers["retry-after"]);
614
619
  if (status === 401) {
615
- return this.makeError("AUTH_EXPIRED", "Leadbay rejected this request (401)", "Leadbay tokens don't expire on a timer, so this isn't a stale token. A 401 here is usually a Leadbay-side hiccup, but can also mean the user logged out. Try again shortly; if it persists, offer to report it to the team.", endpoint, null, status);
620
+ return this.makeError("AUTH_EXPIRED", "Leadbay rejected this request (401)", retried ? "Tokens don't expire on a timer, so this isn't stale. Already auto-retried once and it 401'd again \u2014 usually a Leadbay-side hiccup, but can also mean the user logged out. Try again shortly, else report it." : "Tokens don't expire on a timer, so this isn't stale. This call wasn't auto-retried, so it's the first attempt \u2014 a Leadbay-side hiccup, or the user logged out. Try again once, else report it.", endpoint, null, status);
616
621
  }
617
622
  if (status === 429 || status === 402 || parsed?.error === "quota_exceeded" || parsed?.error?.code === "quota_exceeded") {
618
623
  const hintBase = retryAfter ? `Wait ${retryAfter}s before retrying` : "Wait, then retry";
@@ -715,7 +720,7 @@ var init_client = __esm({
715
720
  try {
716
721
  const res = await this.httpsRequestWithRetry("GET", `${this._baseUrl}${API_PREFIX}/users/me`, { Authorization: `Bearer ${this.token}` }, void 0, opts?.timeoutMs);
717
722
  if (res.status < 200 || res.status >= 300) {
718
- throw this.mapErrorResponse(res.status, res.body, "/users/me", res.headers);
723
+ throw this.mapErrorResponse(res.status, res.body, "/users/me", res.headers, retriesOn401("GET"));
719
724
  }
720
725
  const me = JSON.parse(res.body);
721
726
  const observed = me.telemetry_enabled;
@@ -10289,9 +10294,9 @@ Trigger phrases: "update this contact", "fix this contact's title", "change thei
10289
10294
 
10290
10295
  **Memory:** recall + capture via \`leadbay_agent_memory_*\` tools.
10291
10296
 
10292
- Do NOT use for: "add a new contact to this company" \u2192 \`leadbay_add_contact\`; "remove / delete this contact" \u2192 \`leadbay_remove_contact\`; "get email/phone for a contact (enrichment)" \u2192 \`leadbay_enrich_titles\`.
10297
+ Do NOT use for: "add a new contact to this company" \u2192 \`leadbay_add_contact\`; "remove / delete this contact" \u2192 \`leadbay_remove_contact\`; "get email/phone for a contact (enrichment)" \u2192 \`leadbay_enrich_titles\`; "fix an enriched contact's details" \u2192 \`leadbay_add_contact\`.
10293
10298
 
10294
- Prefer when: user wants to change details on an EXISTING contact \u2014 pass that contact's own \`contact_id\` plus first_name + last_name (required) and the fields to change
10299
+ Prefer when: user wants to change details on a contact that is in their own directory (\`source: "org"\`) \u2014 pass that contact's own \`contact_id\` plus first_name + last_name (required) and the fields to change
10295
10300
 
10296
10301
  Examples that SHOULD invoke this tool:
10297
10302
  - "Update Jane's title to SVP Engineering."
@@ -10311,7 +10316,18 @@ One-line confirmation naming the contact and what changed. No table.
10311
10316
 
10312
10317
  Edit an existing contact in place \u2014 change their \`job_title\`, \`linkedin_page\`, \`email\`, \`phone_number\`, or name.
10313
10318
 
10314
- Pass the contact's **own** \`contact_id\` (the \`id\` field from \`leadbay_research_lead_by_id\` or a contacts list) \u2014 **not** the parent lead id.
10319
+ Pass the contact's **own** \`contact_id\` \u2014 **not** the parent lead id.
10320
+
10321
+ **Only your organization's own directory contacts can be edited.** Leadbay holds contacts in two separate id namespaces and this endpoint resolves one of them:
10322
+
10323
+ | \`source\` on the contact | What it is | Editable here |
10324
+ |---|---|---|
10325
+ | \`"org"\` | A row in your organization's contact directory \u2014 added by you or your team, or promoted from an import | **yes** |
10326
+ | \`"paid"\` | An enrichment result bought from a data provider | **no** \u2014 returns \`NOT_FOUND\` / 404 |
10327
+
10328
+ \`leadbay_research_lead_by_id\` returns both, merged into \`contacts.reachable\` / \`contacts.candidates\` and split by whether the person is messagable right now \u2014 **not** by which namespace they came from. So read \`source\` on the contact before calling this. If it is \`"paid"\` and the user wants different details on record, add the corrected person with \`leadbay_add_contact\` instead; the enrichment row is a provider's answer and is not ours to edit.
10329
+
10330
+ A 404 from this tool almost always means a \`"paid"\` id was passed. Re-read the contact, check \`source\`, and do not retry the same id.
10315
10331
 
10316
10332
  **\`first_name\` + \`last_name\` are required even on an edit.** The backend validates the full contact identity and rejects a partial body (\`invalid contact\`). So pass the contact's *current* first/last name even when you're only changing the title \u2014 read the current values via \`leadbay_research_lead_by_id\` first if you don't have them.
10317
10333
 
@@ -14650,8 +14666,10 @@ var init_create_topup_link = __esm({
14650
14666
  createTopupLink = {
14651
14667
  name: "leadbay_create_topup_link",
14652
14668
  annotations: {
14669
+ // Not read-only: this POSTs a new Stripe Checkout Session into existence.
14670
+ // Clients read readOnlyHint to decide whether to ask the user to confirm.
14653
14671
  title: "Generate Stripe checkout URL for AI-credits top-up",
14654
- readOnlyHint: true,
14672
+ readOnlyHint: false,
14655
14673
  destructiveHint: false,
14656
14674
  idempotentHint: false,
14657
14675
  openWorldHint: true
@@ -14684,8 +14702,12 @@ var init_open_billing_portal = __esm({
14684
14702
  openBillingPortal = {
14685
14703
  name: "leadbay_open_billing_portal",
14686
14704
  annotations: {
14705
+ // Not read-only, despite being a GET: the backend mints a Stripe portal
14706
+ // session, and for an org with no customer yet it also creates the Stripe
14707
+ // customer and persists organizations.stripe_customer_id. Same
14708
+ // getStripeCustomer path as leadbay_create_topup_link.
14687
14709
  title: "Generate Stripe customer-portal URL for subscription management",
14688
- readOnlyHint: true,
14710
+ readOnlyHint: false,
14689
14711
  destructiveHint: false,
14690
14712
  idempotentHint: false,
14691
14713
  openWorldHint: true
@@ -19141,7 +19163,7 @@ var init_research_lead_by_id = __esm({
19141
19163
  },
19142
19164
  contacts: {
19143
19165
  type: "object",
19144
- description: "Two-tier contact set, partitioned by reachability \u2014 agent-friendly framing of the backend's paid-vs-org split. `reachable`: contacts with an email or phone right now (org-directory entries that ship with channels, PLUS paid contacts whose enrichment has completed). The agent can message these without buying enrichment. `candidates`: paid-contact entries WITHOUT resolved channels yet \u2014 typically LinkedIn URL only, `enrichment_done: false`. The agent must call leadbay_enrich_titles (or leadbay_prepare_outreach with enrich:true) before these become messagable.",
19166
+ description: 'Two-tier contact set, partitioned by reachability \u2014 agent-friendly framing of the backend\'s paid-vs-org split. `reachable`: contacts with an email or phone right now (org-directory entries that ship with channels, PLUS paid contacts whose enrichment has completed). The agent can message these without buying enrichment. `candidates`: paid-contact entries WITHOUT resolved channels yet \u2014 typically LinkedIn URL only, `enrichment_done: false`. The agent must call leadbay_enrich_titles (or leadbay_prepare_outreach with enrich:true) before these become messagable. Every contact in both lists carries `source`: `"org"` means it is a row in your organization\'s own contact directory, `"paid"` means it came from enrichment. The two are separate id namespaces on the backend, so only a `source:"org"` id can be passed to leadbay_update_contact / leadbay_remove_contact \u2014 a `"paid"` id returns NOT_FOUND there.',
19145
19167
  properties: {
19146
19168
  reachable: { type: "array", items: { type: "object" } },
19147
19169
  candidates: { type: "array", items: { type: "object" } }
@@ -32961,7 +32983,7 @@ var OAUTH_BASE_URLS = {
32961
32983
  fr: "https://staging.api.leadbay.app"
32962
32984
  }
32963
32985
  };
32964
- var VERSION = "0.32.4";
32986
+ var VERSION = "0.32.6";
32965
32987
  var HELP = `
32966
32988
  leadbay-mcp ${VERSION} \u2014 Leadbay Model Context Protocol server
32967
32989
 
@@ -2797,6 +2797,7 @@ import { readdirSync, readFileSync, existsSync } from "fs";
2797
2797
  import { join } from "path";
2798
2798
  var LENS_CACHE_TTL_MS = 5 * 60 * 1e3;
2799
2799
  var TASTE_CACHE_TTL_MS = 10 * 60 * 1e3;
2800
+ var retriesOn401 = (method) => method.toUpperCase() === "GET";
2800
2801
  var ME_CACHE_TTL_MS = 60 * 1e3;
2801
2802
  var MAX_CONCURRENT = 5;
2802
2803
  var DEFAULT_REQUEST_TIMEOUT_MS = 6e5;
@@ -3154,15 +3155,16 @@ var LeadbayClient = class _LeadbayClient {
3154
3155
  // error envelope says so.
3155
3156
  //
3156
3157
  // Arrow-function field so `this` stays bound even when the method is passed
3157
- // as a bare reference (see request()'s ternary). Retries are GET-ONLY: a 401
3158
- // on a write (POST/PUT/DELETE) may arrive AFTER the mutation already committed
3159
- // server-side, so blindly re-sending it would double-execute the write. Reads
3160
- // are idempotent, so retrying them is safe. The 250ms backoff releases the
3161
- // concurrency slot first (release → sleep → re-acquire) so a wave of 401s
3162
- // doesn't pin all MAX_CONCURRENT slots in setTimeout and stall the queue.
3158
+ // as a bare reference (see request()'s ternary). Retries are GET-ONLY (see
3159
+ // retriesOn401): a 401 on a write (POST/PUT/DELETE) may arrive AFTER the
3160
+ // mutation already committed server-side, so blindly re-sending it would
3161
+ // double-execute the write. Reads are idempotent, so retrying them is safe.
3162
+ // The 250ms backoff releases the concurrency slot first (release → sleep →
3163
+ // re-acquire) so a wave of 401s doesn't pin all MAX_CONCURRENT slots in
3164
+ // setTimeout and stall the queue.
3163
3165
  httpsRequestWithRetry = async (method, url, headers, body, timeoutMs) => {
3164
3166
  const res = await httpsRequest(method, url, headers, body, timeoutMs);
3165
- if (res.status === 401 && method.toUpperCase() === "GET") {
3167
+ if (res.status === 401 && retriesOn401(method)) {
3166
3168
  this.releaseSemaphore();
3167
3169
  try {
3168
3170
  await new Promise((r) => setTimeout(r, 250));
@@ -3181,6 +3183,7 @@ var LeadbayClient = class _LeadbayClient {
3181
3183
  throw this.makeError("NOT_AUTHENTICATED", "Not logged in to Leadbay", "Set LEADBAY_TOKEN in your MCP client config, or run: npx -y -p @leadbay/mcp@latest installer", path);
3182
3184
  }
3183
3185
  const retryOn401 = opts?.retryOn401 !== false;
3186
+ const retriedOn401 = retryOn401 && retriesOn401(method);
3184
3187
  await this.acquireSemaphore();
3185
3188
  try {
3186
3189
  const url = `${this._baseUrl}${API_PREFIX}${path}`;
@@ -3201,7 +3204,7 @@ var LeadbayClient = class _LeadbayClient {
3201
3204
  return null;
3202
3205
  }
3203
3206
  if (res.status < 200 || res.status >= 300) {
3204
- throw this.mapErrorResponse(res.status, res.body, path, res.headers);
3207
+ throw this.mapErrorResponse(res.status, res.body, path, res.headers, retriedOn401);
3205
3208
  }
3206
3209
  return JSON.parse(res.body);
3207
3210
  } catch (e) {
@@ -3211,6 +3214,7 @@ var LeadbayClient = class _LeadbayClient {
3211
3214
  }
3212
3215
  }
3213
3216
  async requestVoid(method, path, body) {
3217
+ const retriedOn401 = retriesOn401(method);
3214
3218
  if (process.env.LEADBAY_MOCK === "1") {
3215
3219
  await this.mockRequest(method, path, body);
3216
3220
  return;
@@ -3235,7 +3239,7 @@ var LeadbayClient = class _LeadbayClient {
3235
3239
  retry_after: parseRetryAfter(res.headers["retry-after"])
3236
3240
  };
3237
3241
  if (res.status < 200 || res.status >= 300) {
3238
- throw this.mapErrorResponse(res.status, res.body, path, res.headers);
3242
+ throw this.mapErrorResponse(res.status, res.body, path, res.headers, retriedOn401);
3239
3243
  }
3240
3244
  } catch (e) {
3241
3245
  throw this.mapTransportError(e, `${method} ${path}`);
@@ -3249,6 +3253,7 @@ var LeadbayClient = class _LeadbayClient {
3249
3253
  // mirror request() exactly. Used by leadbay_import_leads to upload CSVs to
3250
3254
  // the wizard at POST /1.6/imports.
3251
3255
  async requestRawBinary(method, path, contentType, body) {
3256
+ const retriedOn401 = retriesOn401(method);
3252
3257
  if (process.env.LEADBAY_MOCK === "1") {
3253
3258
  return this.mockRequestBinary(method, path, contentType, body);
3254
3259
  }
@@ -3273,7 +3278,7 @@ var LeadbayClient = class _LeadbayClient {
3273
3278
  return null;
3274
3279
  }
3275
3280
  if (res.status < 200 || res.status >= 300) {
3276
- throw this.mapErrorResponse(res.status, res.body, path, res.headers);
3281
+ throw this.mapErrorResponse(res.status, res.body, path, res.headers, retriedOn401);
3277
3282
  }
3278
3283
  return JSON.parse(res.body);
3279
3284
  } catch (e) {
@@ -3361,7 +3366,7 @@ var LeadbayClient = class _LeadbayClient {
3361
3366
  }
3362
3367
  return envelope;
3363
3368
  }
3364
- mapErrorResponse(status, rawBody, endpoint, headers) {
3369
+ mapErrorResponse(status, rawBody, endpoint, headers, retried) {
3365
3370
  let parsed;
3366
3371
  try {
3367
3372
  parsed = JSON.parse(rawBody);
@@ -3370,7 +3375,7 @@ var LeadbayClient = class _LeadbayClient {
3370
3375
  }
3371
3376
  const retryAfter = parseRetryAfter(headers["retry-after"]);
3372
3377
  if (status === 401) {
3373
- return this.makeError("AUTH_EXPIRED", "Leadbay rejected this request (401)", "Leadbay tokens don't expire on a timer, so this isn't a stale token. A 401 here is usually a Leadbay-side hiccup, but can also mean the user logged out. Try again shortly; if it persists, offer to report it to the team.", endpoint, null, status);
3378
+ return this.makeError("AUTH_EXPIRED", "Leadbay rejected this request (401)", retried ? "Tokens don't expire on a timer, so this isn't stale. Already auto-retried once and it 401'd again \u2014 usually a Leadbay-side hiccup, but can also mean the user logged out. Try again shortly, else report it." : "Tokens don't expire on a timer, so this isn't stale. This call wasn't auto-retried, so it's the first attempt \u2014 a Leadbay-side hiccup, or the user logged out. Try again once, else report it.", endpoint, null, status);
3374
3379
  }
3375
3380
  if (status === 429 || status === 402 || parsed?.error === "quota_exceeded" || parsed?.error?.code === "quota_exceeded") {
3376
3381
  const hintBase = retryAfter ? `Wait ${retryAfter}s before retrying` : "Wait, then retry";
@@ -3473,7 +3478,7 @@ var LeadbayClient = class _LeadbayClient {
3473
3478
  try {
3474
3479
  const res = await this.httpsRequestWithRetry("GET", `${this._baseUrl}${API_PREFIX}/users/me`, { Authorization: `Bearer ${this.token}` }, void 0, opts?.timeoutMs);
3475
3480
  if (res.status < 200 || res.status >= 300) {
3476
- throw this.mapErrorResponse(res.status, res.body, "/users/me", res.headers);
3481
+ throw this.mapErrorResponse(res.status, res.body, "/users/me", res.headers, retriesOn401("GET"));
3477
3482
  }
3478
3483
  const me = JSON.parse(res.body);
3479
3484
  const observed = me.telemetry_enabled;
@@ -12682,9 +12687,9 @@ Trigger phrases: "update this contact", "fix this contact's title", "change thei
12682
12687
 
12683
12688
  **Memory:** recall + capture via \`leadbay_agent_memory_*\` tools.
12684
12689
 
12685
- Do NOT use for: "add a new contact to this company" \u2192 \`leadbay_add_contact\`; "remove / delete this contact" \u2192 \`leadbay_remove_contact\`; "get email/phone for a contact (enrichment)" \u2192 \`leadbay_enrich_titles\`.
12690
+ Do NOT use for: "add a new contact to this company" \u2192 \`leadbay_add_contact\`; "remove / delete this contact" \u2192 \`leadbay_remove_contact\`; "get email/phone for a contact (enrichment)" \u2192 \`leadbay_enrich_titles\`; "fix an enriched contact's details" \u2192 \`leadbay_add_contact\`.
12686
12691
 
12687
- Prefer when: user wants to change details on an EXISTING contact \u2014 pass that contact's own \`contact_id\` plus first_name + last_name (required) and the fields to change
12692
+ Prefer when: user wants to change details on a contact that is in their own directory (\`source: "org"\`) \u2014 pass that contact's own \`contact_id\` plus first_name + last_name (required) and the fields to change
12688
12693
 
12689
12694
  Examples that SHOULD invoke this tool:
12690
12695
  - "Update Jane's title to SVP Engineering."
@@ -12704,7 +12709,18 @@ One-line confirmation naming the contact and what changed. No table.
12704
12709
 
12705
12710
  Edit an existing contact in place \u2014 change their \`job_title\`, \`linkedin_page\`, \`email\`, \`phone_number\`, or name.
12706
12711
 
12707
- Pass the contact's **own** \`contact_id\` (the \`id\` field from \`leadbay_research_lead_by_id\` or a contacts list) \u2014 **not** the parent lead id.
12712
+ Pass the contact's **own** \`contact_id\` \u2014 **not** the parent lead id.
12713
+
12714
+ **Only your organization's own directory contacts can be edited.** Leadbay holds contacts in two separate id namespaces and this endpoint resolves one of them:
12715
+
12716
+ | \`source\` on the contact | What it is | Editable here |
12717
+ |---|---|---|
12718
+ | \`"org"\` | A row in your organization's contact directory \u2014 added by you or your team, or promoted from an import | **yes** |
12719
+ | \`"paid"\` | An enrichment result bought from a data provider | **no** \u2014 returns \`NOT_FOUND\` / 404 |
12720
+
12721
+ \`leadbay_research_lead_by_id\` returns both, merged into \`contacts.reachable\` / \`contacts.candidates\` and split by whether the person is messagable right now \u2014 **not** by which namespace they came from. So read \`source\` on the contact before calling this. If it is \`"paid"\` and the user wants different details on record, add the corrected person with \`leadbay_add_contact\` instead; the enrichment row is a provider's answer and is not ours to edit.
12722
+
12723
+ A 404 from this tool almost always means a \`"paid"\` id was passed. Re-read the contact, check \`source\`, and do not retry the same id.
12708
12724
 
12709
12725
  **\`first_name\` + \`last_name\` are required even on an edit.** The backend validates the full contact identity and rejects a partial body (\`invalid contact\`). So pass the contact's *current* first/last name even when you're only changing the title \u2014 read the current values via \`leadbay_research_lead_by_id\` first if you don't have them.
12710
12726
 
@@ -16819,8 +16835,10 @@ function coerceCsvValue(v) {
16819
16835
  var createTopupLink = {
16820
16836
  name: "leadbay_create_topup_link",
16821
16837
  annotations: {
16838
+ // Not read-only: this POSTs a new Stripe Checkout Session into existence.
16839
+ // Clients read readOnlyHint to decide whether to ask the user to confirm.
16822
16840
  title: "Generate Stripe checkout URL for AI-credits top-up",
16823
- readOnlyHint: true,
16841
+ readOnlyHint: false,
16824
16842
  destructiveHint: false,
16825
16843
  idempotentHint: false,
16826
16844
  openWorldHint: true
@@ -16846,8 +16864,12 @@ var createTopupLink = {
16846
16864
  var openBillingPortal = {
16847
16865
  name: "leadbay_open_billing_portal",
16848
16866
  annotations: {
16867
+ // Not read-only, despite being a GET: the backend mints a Stripe portal
16868
+ // session, and for an org with no customer yet it also creates the Stripe
16869
+ // customer and persists organizations.stripe_customer_id. Same
16870
+ // getStripeCustomer path as leadbay_create_topup_link.
16849
16871
  title: "Generate Stripe customer-portal URL for subscription management",
16850
- readOnlyHint: true,
16872
+ readOnlyHint: false,
16851
16873
  destructiveHint: false,
16852
16874
  idempotentHint: false,
16853
16875
  openWorldHint: true
@@ -20921,7 +20943,7 @@ var researchLeadById = {
20921
20943
  },
20922
20944
  contacts: {
20923
20945
  type: "object",
20924
- description: "Two-tier contact set, partitioned by reachability \u2014 agent-friendly framing of the backend's paid-vs-org split. `reachable`: contacts with an email or phone right now (org-directory entries that ship with channels, PLUS paid contacts whose enrichment has completed). The agent can message these without buying enrichment. `candidates`: paid-contact entries WITHOUT resolved channels yet \u2014 typically LinkedIn URL only, `enrichment_done: false`. The agent must call leadbay_enrich_titles (or leadbay_prepare_outreach with enrich:true) before these become messagable.",
20946
+ description: 'Two-tier contact set, partitioned by reachability \u2014 agent-friendly framing of the backend\'s paid-vs-org split. `reachable`: contacts with an email or phone right now (org-directory entries that ship with channels, PLUS paid contacts whose enrichment has completed). The agent can message these without buying enrichment. `candidates`: paid-contact entries WITHOUT resolved channels yet \u2014 typically LinkedIn URL only, `enrichment_done: false`. The agent must call leadbay_enrich_titles (or leadbay_prepare_outreach with enrich:true) before these become messagable. Every contact in both lists carries `source`: `"org"` means it is a row in your organization\'s own contact directory, `"paid"` means it came from enrichment. The two are separate id namespaces on the backend, so only a `source:"org"` id can be passed to leadbay_update_contact / leadbay_remove_contact \u2014 a `"paid"` id returns NOT_FOUND there.',
20925
20947
  properties: {
20926
20948
  reachable: { type: "array", items: { type: "object" } },
20927
20949
  candidates: { type: "array", items: { type: "object" } }
@@ -29547,7 +29569,7 @@ function parseWriteEnv(env = process.env) {
29547
29569
  }
29548
29570
 
29549
29571
  // src/http-server.ts
29550
- var VERSION = true ? "0.32.4" : "0.0.0-dev";
29572
+ var VERSION = true ? "0.32.6" : "0.0.0-dev";
29551
29573
  var PORT = Number(process.env.PORT ?? 8080);
29552
29574
  var HOST = process.env.HOST ?? "0.0.0.0";
29553
29575
  var logger = {
@@ -1804,7 +1804,7 @@ var init_installer_gui = __esm({
1804
1804
  init_install_dxt();
1805
1805
  init_install_shared();
1806
1806
  init_oauth();
1807
- VERSION = true ? "0.32.4" : "0.0.0-dev";
1807
+ VERSION = true ? "0.32.6" : "0.0.0-dev";
1808
1808
  MESSAGES = {
1809
1809
  en: {
1810
1810
  installer: {
@@ -1067,7 +1067,7 @@ async function oauthLogin(opts) {
1067
1067
  }
1068
1068
 
1069
1069
  // installer/installer-gui.ts
1070
- var VERSION = true ? "0.32.4" : "0.0.0-dev";
1070
+ var VERSION = true ? "0.32.6" : "0.0.0-dev";
1071
1071
  var MESSAGES = {
1072
1072
  en: {
1073
1073
  installer: {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@leadbay/mcp",
3
- "version": "0.32.4",
3
+ "version": "0.32.6",
4
4
  "mcpName": "io.github.leadbay/leadbay-mcp",
5
5
  "description": "Model Context Protocol (MCP) server for Leadbay — AI lead discovery, qualification, and enrichment for Claude Desktop, Cursor, and Claude Code.",
6
6
  "type": "module",