@leadbay/mcp 0.33.1 → 0.33.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,109 @@
1
1
  # Changelog — @leadbay/mcp
2
2
 
3
+ ## 0.33.3 — 2026-09-02
4
+
5
+ `leadbay_pin_contact` failed 43 of its 48 production calls.
6
+
7
+ Measured over the 180 days to 2026-09-02 (PostHog `mcp tool called`,
8
+ `properties.ok = false`). Sentry issue `MCP-3A` carries exactly 43 events, so
9
+ every `contact not found` in the `mcp` project is a pin call. 41 of the 43 are
10
+ one scheduled agent, `zoe+dogfood@leadbay.ai`, across 12 days and MCP 0.26.0
11
+ through 0.33.2. Its own `triggered_by` reads *"tâche planifiée : épingler le DG
12
+ Mickael Hamot (CROMOLOGY SERVICES) à la place du président"*. It picks the DG
13
+ out of `leadbay_research_lead_by_id` and pins that id, which on an unenriched
14
+ company is a `source: "paid"` candidate. It has never once succeeded, and pin
15
+ is its third most used tool.
16
+
17
+ `POST /contacts/{id}/pin` resolves through `org_contacts` only
18
+ (`OrgContactRoutes.kt:44`), so a paid candidate id can never resolve there. Two
19
+ changes:
20
+
21
+ - **The 404 now names its cause.** The client's shared 404 hint is "Verify the
22
+ ID is correct" (`client.ts:1048`), which on this endpoint is false: the id is
23
+ correct, it is the wrong namespace. An agent reads that as "look it up and
24
+ retry" and does, in bursts of up to 10 calls in 13 seconds. pin and unpin now
25
+ catch `NOT_FOUND` and replace the hint with one that names the org-vs-paid
26
+ split, says not to retry, and names the tools that make the person pinnable.
27
+ - **The descriptions carry the rule.** New shared snippet
28
+ `snippets/heuristics/pinnable-contacts.md`, included by both templates, plus
29
+ the rule in `prefer_when` so it lands in the first 600 chars every host reads.
30
+ It also states that pinning does not steer enrichment: enrichment selects by
31
+ job title (`resolveAutoIncludedTitles` →
32
+ `paidContacts.findSimilarJobTitlesWithScores`) and `pinnedBy` plays no part.
33
+ That was the second half of the agent's mistake.
34
+
35
+ **`pinned` is now readable.** The backend's `ContactPayload` has carried
36
+ `pinned` + `pinned_by_ai` all along; every MCP shaping site dropped them, so a
37
+ pin could be written but not read back except by watching `recommended`, which
38
+ moves for other reasons too. Now passed through in `research_lead_by_id`,
39
+ `get_contacts` and `get_lead_profile`, and marked `📌` in both markdown contact
40
+ lists. Deliberately asymmetric: `PaidContactPayload` has no pin state, so paid
41
+ contacts get no `pinned` key rather than a synthetic `false`.
42
+
43
+ Verified live against FR staging, running the built branch:
44
+
45
+ ```
46
+ BEFORE PIN: org contact → pinned: false paid contacts → no pinned key
47
+ AFTER PIN: org contact → pinned: true (pin http 204)
48
+ AFTER UNPIN: org contact → pinned: false (unpin http 204)
49
+ ```
50
+
51
+ Remaining, not fixed here: on hosted an agent still has no way to enrich one
52
+ named person. `leadbay_enrich_contacts` takes a `contact_id` and does exactly
53
+ that, but sits in `granularWriteTools` behind `LEADBAY_MCP_ADVANCED=1`, and no
54
+ granular tool has been called from a hosted IP in 30 days. That is why the
55
+ agent reached for pin. Filed as leadbay/product#4050.
56
+
57
+ ## 0.33.2 — 2026-09-02
58
+
59
+ Editing one field on a contact erased the others (product#4046).
60
+
61
+ Reproduced on production while verifying leadbay/mcp#194: sending a contact's
62
+ own current `first_name` + `last_name` + `job_title`, changing nothing, deleted
63
+ that contact's email. The contact moved from `contacts.reachable` to
64
+ `contacts.candidates` and `_meta.has_reachable_contact` flipped to false, so the
65
+ lead stopped being contactable. Restored with a second call.
66
+
67
+ This became reachable only the day before: until #194 the tool returned 404 on
68
+ 100% of calls, so it destroyed nothing. A tool that always failed now succeeded
69
+ and deleted data.
70
+
71
+ **The backend already had the safe behaviour and we were choosing the other
72
+ one.** Both routes take the same payload
73
+ (`OrgContactRoutes.kt:110,152` → `OrgContactsDaoImpl.kt:265-272`):
74
+
75
+ | Route | `forceUpdateIfNullOrEmpty` | A field absent from the body |
76
+ |---|---|---|
77
+ | `/contacts/{id}/update` | `true` | written as null — **erased** |
78
+ | `/contacts/{id}/merge` | `false` | skipped — **kept** |
79
+
80
+ So the fix needs no read-modify-write, no extra round-trip and no race window.
81
+ `leadbay_update_contact` now routes by intent:
82
+
83
+ - **Nothing being erased → `/merge`.** Every field the caller omitted survives.
84
+ - **Any field passed as `null` → `/update`**, which rewrites the record — so
85
+ that call must carry all four optional fields. If it does not, the tool
86
+ refuses with `CONTACT_CLEAR_NEEDS_FULL_RECORD` rather than deleting what was
87
+ not mentioned. The destructive path costs more effort than the safe one,
88
+ which is the intended asymmetry.
89
+ - The result carries `mode` (`merge` / `replace`), `preserved` and `cleared`, so
90
+ a wrong edit is visible rather than silent.
91
+
92
+ Verified live on staging, same body to each route:
93
+
94
+ ```
95
+ POST /contacts/{id}/merge {first_name, last_name, job_title:"CEO"}
96
+ → job_title CEO, email merge.probe@example.test, phone +15550001111 (kept)
97
+ POST /contacts/{id}/update {first_name, last_name, job_title:"CTO"}
98
+ → job_title CTO, email absent, phone absent (erased)
99
+ ```
100
+
101
+ Two existing test files were changed rather than added, because both asserted
102
+ the contract this fixes: `update-contact.test.ts`'s happy path now expects
103
+ `/merge`, and `update-contact-null-clear.test.ts`'s clear case now supplies the
104
+ whole record. The second had encoded the data loss as correct — it asserted that
105
+ unmentioned fields are not sent, while posting to the route that deletes them.
106
+
3
107
  ## 0.33.1 — 2026-09-02
4
108
 
5
109
  Follow-up to 0.32.0 (product#4007). A review finding landed after the merge.
package/dist/bin.js CHANGED
@@ -3688,7 +3688,7 @@ Trigger phrases: "pin this contact", "mark this person as priority", "make this
3688
3688
 
3689
3689
  Do NOT use for: "unpin / remove the pin" \u2192 \`leadbay_unpin_contact\`; "add a contact to this company" \u2192 \`leadbay_add_contact\`; "remove / delete this contact" \u2192 \`leadbay_remove_contact\`.
3690
3690
 
3691
- Prefer when: user wants ONE person flagged as the priority on a company \u2014 pass that contact's own \`contact_id\`
3691
+ Prefer when: user wants ONE person flagged as the priority on a company \u2014 pass that contact's own \`contact_id\`, and ONLY a \`source:"org"\` contact can be pinned (a \`source:"paid"\` candidate returns 'contact not found')
3692
3692
 
3693
3693
  Examples that SHOULD invoke this tool:
3694
3694
  - "Pin Jane Doe as the main contact on this company."
@@ -3710,9 +3710,24 @@ Pin a single contact on a company so it surfaces first as a priority / favourite
3710
3710
 
3711
3711
  Pass the contact's **own** \`contact_id\` (the \`id\` field on a contact object from \`leadbay_research_lead_by_id\` or a contacts list) \u2014 **not** the parent lead id.
3712
3712
 
3713
+ **Only \`source: "org"\` contacts are pinnable.** Every contact returned by \`leadbay_research_lead_by_id\` carries a \`source\` field, and the two sources are separate id namespaces on the backend:
3714
+
3715
+ - \`source: "org"\` \u2014 a row in your organization's own contact directory. Pinnable. Also carries \`pinned\` (true when someone has pinned it) and \`pinned_by_ai\` (true when Leadbay's AI pinned it rather than a human).
3716
+ - \`source: "paid"\` \u2014 an enrichment *candidate* (the \`candidates\` bucket): a person Leadbay suggests but has not yet resolved into your directory. NOT pinnable, and carries no \`pinned\` field at all.
3717
+
3718
+ Passing a \`source: "paid"\` id here returns **\`contact not found\`**. That is the expected answer for a candidate, not an outage and not a transient error: nothing is broken, the person is simply not an org contact yet. Do not retry, do not re-fetch the lead hoping for a different result, and do not tell the user that pinning is failing or unavailable.
3719
+
3720
+ To pin someone who is currently only a candidate, first make them an org contact:
3721
+
3722
+ - \`leadbay_enrich_titles\` (or \`leadbay_prepare_outreach\` with \`enrich: true\`) resolves the candidate and writes a NEW org contact for that person. It has a **different \`id\`** from the paid candidate, so re-read the contacts list afterwards and pin the \`source: "org"\` row.
3723
+ - Or add them directly with \`leadbay_add_contact\`, which returns the new org contact's \`id\` \u2014 that id is pinnable immediately.
3724
+
3725
+ **Pinning does not steer enrichment.** It only marks who the priority is on a company the user already has. Enrichment picks people by JOB TITLE, so "enrich the Directeur G\xE9n\xE9ral rather than the Pr\xE9sident" is \`leadbay_enrich_titles\` with the wanted title \u2014 not a pin. Pinning first and enriching after changes nothing about who gets enriched.
3726
+
3727
+
3713
3728
  Backend: \`POST /contacts/{contact_id}/pin\` \u2192 204. Idempotent. The inverse is \`leadbay_unpin_contact\`.
3714
3729
 
3715
- Returns \`{ pinned: true, contact_id, action: "pinned" }\`.
3730
+ Returns \`{ pinned: true, contact_id, action: "pinned" }\`. To read the resulting state back, re-call \`leadbay_research_lead_by_id\` \u2014 the pinned contact's \`pinned\` flips to \`true\` and it becomes the lead's \`recommended\` contact.
3716
3731
 
3717
3732
  Requires: LEADBAY_MCP_WRITE=1 (MCP) or exposeWrite=true (OpenClaw).
3718
3733
  `;
@@ -5657,7 +5672,7 @@ Trigger phrases: "unpin this contact", "remove the pin from this contact", "this
5657
5672
 
5658
5673
  Do NOT use for: "pin / mark as priority" \u2192 \`leadbay_pin_contact\`; "remove / delete this contact" \u2192 \`leadbay_remove_contact\`.
5659
5674
 
5660
- Prefer when: user wants to clear the pinned flag on a contact (but keep the contact) \u2014 pass that contact's own \`contact_id\`
5675
+ Prefer when: user wants to clear the pinned flag on a contact (but keep the contact) \u2014 pass that contact's own \`contact_id\`, and ONLY a \`source:"org"\` contact can be unpinned (a \`source:"paid"\` candidate returns 'contact not found')
5661
5676
 
5662
5677
  Examples that SHOULD invoke this tool:
5663
5678
  - "Unpin Jane Doe \u2014 she's not the priority anymore."
@@ -5680,9 +5695,26 @@ Unpin a single contact on a company \u2014 clears its priority / favourite flag.
5680
5695
 
5681
5696
  Pass the contact's **own** \`contact_id\` \u2014 not the parent lead id.
5682
5697
 
5698
+ **Only \`source: "org"\` contacts are pinnable.** Every contact returned by \`leadbay_research_lead_by_id\` carries a \`source\` field, and the two sources are separate id namespaces on the backend:
5699
+
5700
+ - \`source: "org"\` \u2014 a row in your organization's own contact directory. Pinnable. Also carries \`pinned\` (true when someone has pinned it) and \`pinned_by_ai\` (true when Leadbay's AI pinned it rather than a human).
5701
+ - \`source: "paid"\` \u2014 an enrichment *candidate* (the \`candidates\` bucket): a person Leadbay suggests but has not yet resolved into your directory. NOT pinnable, and carries no \`pinned\` field at all.
5702
+
5703
+ Passing a \`source: "paid"\` id here returns **\`contact not found\`**. That is the expected answer for a candidate, not an outage and not a transient error: nothing is broken, the person is simply not an org contact yet. Do not retry, do not re-fetch the lead hoping for a different result, and do not tell the user that pinning is failing or unavailable.
5704
+
5705
+ To pin someone who is currently only a candidate, first make them an org contact:
5706
+
5707
+ - \`leadbay_enrich_titles\` (or \`leadbay_prepare_outreach\` with \`enrich: true\`) resolves the candidate and writes a NEW org contact for that person. It has a **different \`id\`** from the paid candidate, so re-read the contacts list afterwards and pin the \`source: "org"\` row.
5708
+ - Or add them directly with \`leadbay_add_contact\`, which returns the new org contact's \`id\` \u2014 that id is pinnable immediately.
5709
+
5710
+ **Pinning does not steer enrichment.** It only marks who the priority is on a company the user already has. Enrichment picks people by JOB TITLE, so "enrich the Directeur G\xE9n\xE9ral rather than the Pr\xE9sident" is \`leadbay_enrich_titles\` with the wanted title \u2014 not a pin. Pinning first and enriching after changes nothing about who gets enriched.
5711
+
5712
+
5713
+ A \`source: "org"\` contact that was never pinned is a no-op here, not an error \u2014 the backend answers 204 either way. Check \`pinned\` on the contact before calling if you need to tell the user whether anything actually changed.
5714
+
5683
5715
  Backend: \`POST /contacts/{contact_id}/unpin\` \u2192 204. Idempotent. The inverse is \`leadbay_pin_contact\`.
5684
5716
 
5685
- Returns \`{ pinned: false, contact_id, action: "unpinned" }\`.
5717
+ Returns \`{ pinned: false, contact_id, action: "unpinned" }\`. To read the resulting state back, re-call \`leadbay_research_lead_by_id\` \u2014 the contact's \`pinned\` flips to \`false\` and the lead's \`recommended\` contact reverts to the title-matched default.
5686
5718
 
5687
5719
  Requires: LEADBAY_MCP_WRITE=1 (MCP) or exposeWrite=true (OpenClaw).
5688
5720
  `;
@@ -5692,7 +5724,7 @@ Trigger phrases: "update this contact", "fix this contact's title", "change thei
5692
5724
 
5693
5725
  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\`.
5694
5726
 
5695
- 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
5727
+ Prefer when: user wants to change details on a contact that is in their own directory (\`source: "org"\`) \u2014 pass its \`contact_id\`, first_name, last_name and only the fields being changed
5696
5728
 
5697
5729
  Examples that SHOULD invoke this tool:
5698
5730
  - "Update Jane's title to SVP Engineering."
@@ -5725,11 +5757,30 @@ Pass the contact's **own** \`contact_id\` \u2014 **not** the parent lead id.
5725
5757
 
5726
5758
  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.
5727
5759
 
5728
- **\`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.
5760
+ ## Omitting a field keeps it. Erasing one takes an explicit \`null\`.
5761
+
5762
+ Send only what you are changing. Any field you leave out keeps its current value \u2014 you do NOT need to read the contact first and echo everything back.
5763
+
5764
+ \`\`\`
5765
+ { contact_id, first_name, last_name, job_title: "CEO" }
5766
+ \u2192 title becomes CEO. email, phone and LinkedIn are untouched.
5767
+ \`\`\`
5768
+
5769
+ **To erase a field, pass it as \`null\`.** Because erasing rewrites the whole record, that call must carry ALL of \`job_title\`, \`linkedin_page\`, \`email\`, \`phone_number\` \u2014 current value for the ones to keep, \`null\` for the ones to erase. If any are missing the call is refused with \`CONTACT_CLEAR_NEEDS_FULL_RECORD\` rather than deleting them; read the contact with \`leadbay_research_lead_by_id\` and re-call.
5770
+
5771
+ \`\`\`
5772
+ { contact_id, first_name, last_name, email: null,
5773
+ job_title: "CEO", phone_number: "+33\u2026", linkedin_page: "https://\u2026" }
5774
+ \u2192 email erased, everything else as given.
5775
+ \`\`\`
5776
+
5777
+ \`first_name\` + \`last_name\` are required on every call. The backend validates the contact's identity and rejects a body without them (\`invalid contact\`), so pass the current values when you are not changing the name.
5729
5778
 
5730
- Backend: \`POST /contacts/{contact_id}/update\` (snake_case body) \u2192 200 with the updated contact. Edits in place (same id). Camel-case bodies are rejected.
5779
+ The result tells you which happened: \`mode\` is \`merge\` or \`replace\`, \`preserved\` lists the fields left untouched, \`cleared\` lists the fields erased. **Check \`cleared\` is what you intended.**
5731
5780
 
5732
- Returns \`{ updated: true, contact_id, contact: { id, first_name, last_name, job_title, linkedin_page, email, phone_number } }\`.
5781
+ Backend: \`POST /contacts/{contact_id}/merge\` when nothing is being erased, \`POST /contacts/{contact_id}/update\` when something is. Snake_case body; camel-case is rejected. Edits in place (same id).
5782
+
5783
+ Returns \`{ updated: true, contact_id, mode, preserved, cleared, contact: { id, first_name, last_name, job_title, linkedin_page, email, phone_number } }\`.
5733
5784
 
5734
5785
  Requires: LEADBAY_MCP_WRITE=1 (MCP) or exposeWrite=true (OpenClaw).
5735
5786
  `;
@@ -6067,6 +6118,10 @@ var init_get_lead_profile = __esm({
6067
6118
  job_title: c.job_title,
6068
6119
  recommended: c.recommended,
6069
6120
  enrichment: c.enrichment,
6121
+ // Org contacts only — the backend's PaidContactPayload carries no pin
6122
+ // state, because a paid candidate cannot be pinned.
6123
+ pinned: c.pinned ?? false,
6124
+ pinned_by_ai: c.pinned_by_ai ?? false,
6070
6125
  source: "org"
6071
6126
  })),
6072
6127
  ...paidContacts.map((c) => ({
@@ -6158,7 +6213,7 @@ var init_get_contacts = __esm({
6158
6213
  properties: {
6159
6214
  contacts: {
6160
6215
  type: "array",
6161
- description: "Merged org+paid contacts. Each: {id, first_name, last_name, email, phone_number, linkedin_page, job_title, recommended, enrichment, source:'org'|'paid'}.",
6216
+ description: "Merged org+paid contacts. Each: {id, first_name, last_name, email, phone_number, linkedin_page, job_title, recommended, enrichment, source:'org'|'paid'}. `source:'org'` entries additionally carry {pinned, pinned_by_ai}; `source:'paid'` entries do not, because a paid candidate cannot be pinned \u2014 passing its id to leadbay_pin_contact / leadbay_unpin_contact returns NOT_FOUND.",
6162
6217
  items: {
6163
6218
  type: "object",
6164
6219
  properties: {
@@ -6170,6 +6225,14 @@ var init_get_contacts = __esm({
6170
6225
  linkedin_page: { type: ["string", "null"] },
6171
6226
  job_title: { type: ["string", "null"] },
6172
6227
  recommended: { type: "boolean" },
6228
+ pinned: {
6229
+ type: "boolean",
6230
+ description: "Someone flagged this person as the priority on the company. Present on source:'org' contacts only."
6231
+ },
6232
+ pinned_by_ai: {
6233
+ type: "boolean",
6234
+ description: "The pin came from Leadbay's AI rather than a human. Present on source:'org' contacts only."
6235
+ },
6173
6236
  source: { type: "string", enum: ["org", "paid"] },
6174
6237
  enrichment: {
6175
6238
  type: ["object", "null"],
@@ -6228,6 +6291,10 @@ var init_get_contacts = __esm({
6228
6291
  job_title: c.job_title,
6229
6292
  recommended: c.recommended,
6230
6293
  enrichment: c.enrichment,
6294
+ // Org contacts only — the backend's PaidContactPayload carries no
6295
+ // pin state, because a paid candidate cannot be pinned.
6296
+ pinned: c.pinned ?? false,
6297
+ pinned_by_ai: c.pinned_by_ai ?? false,
6231
6298
  source: "org"
6232
6299
  })),
6233
6300
  ...paidContacts.map((c) => ({
@@ -11766,11 +11833,12 @@ var init_remove_contact = __esm({
11766
11833
  });
11767
11834
 
11768
11835
  // ../core/dist/tools/pin-contact.js
11769
- var pinContact;
11836
+ var NOT_PINNABLE_HINT, pinContact;
11770
11837
  var init_pin_contact = __esm({
11771
11838
  "../core/dist/tools/pin-contact.js"() {
11772
11839
  "use strict";
11773
11840
  init_tool_descriptions_generated();
11841
+ NOT_PINNABLE_HINT = 'This contact id is not in your organization\'s contact directory, so it cannot be pinned or unpinned. Almost always it is a `source: "paid"` enrichment candidate from leadbay_research_lead_by_id\'s `candidates` list; only `source: "org"` contacts are pinnable. The id is not wrong and the tool is not broken, so do NOT retry it. To act on this person, enrich them by job title with leadbay_enrich_titles, or add them with leadbay_add_contact \u2014 either produces a NEW org contact with a different id, which is pinnable. Note that pinning does not decide who gets enriched; enrichment selects people by job title.';
11774
11842
  pinContact = {
11775
11843
  name: "leadbay_pin_contact",
11776
11844
  description: leadbay_pin_contact,
@@ -11794,7 +11862,13 @@ var init_pin_contact = __esm({
11794
11862
  additionalProperties: false
11795
11863
  },
11796
11864
  execute: async (client, params, _ctx) => {
11797
- await client.requestVoid("POST", `/contacts/${params.contact_id}/pin`);
11865
+ try {
11866
+ await client.requestVoid("POST", `/contacts/${params.contact_id}/pin`);
11867
+ } catch (e) {
11868
+ if (e?.code === "NOT_FOUND")
11869
+ throw { ...e, hint: NOT_PINNABLE_HINT };
11870
+ throw e;
11871
+ }
11798
11872
  return { pinned: true, contact_id: params.contact_id, action: "pinned" };
11799
11873
  }
11800
11874
  };
@@ -11807,6 +11881,7 @@ var init_unpin_contact = __esm({
11807
11881
  "../core/dist/tools/unpin-contact.js"() {
11808
11882
  "use strict";
11809
11883
  init_tool_descriptions_generated();
11884
+ init_pin_contact();
11810
11885
  unpinContact = {
11811
11886
  name: "leadbay_unpin_contact",
11812
11887
  description: leadbay_unpin_contact,
@@ -11830,7 +11905,13 @@ var init_unpin_contact = __esm({
11830
11905
  additionalProperties: false
11831
11906
  },
11832
11907
  execute: async (client, params, _ctx) => {
11833
- await client.requestVoid("POST", `/contacts/${params.contact_id}/unpin`);
11908
+ try {
11909
+ await client.requestVoid("POST", `/contacts/${params.contact_id}/unpin`);
11910
+ } catch (e) {
11911
+ if (e?.code === "NOT_FOUND")
11912
+ throw { ...e, hint: NOT_PINNABLE_HINT };
11913
+ throw e;
11914
+ }
11834
11915
  return { pinned: false, contact_id: params.contact_id, action: "unpinned" };
11835
11916
  }
11836
11917
  };
@@ -11873,39 +11954,59 @@ var init_update_contact = __esm({
11873
11954
  // new value. execute forwards null verbatim; the backend accepts it.
11874
11955
  job_title: {
11875
11956
  type: ["string", "null"],
11876
- description: "Contact job title. Pass null to clear it."
11957
+ description: "Contact job title. Omit it to leave it unchanged. Pass null to ERASE it \u2014 an erase rewrites the whole contact, so that call must also carry every other optional field, or it is refused."
11877
11958
  },
11878
11959
  linkedin_page: {
11879
11960
  type: ["string", "null"],
11880
- description: "Contact LinkedIn URL. Pass null to clear it."
11961
+ description: "Contact LinkedIn URL. Omit it to leave it unchanged. Pass null to ERASE it \u2014 an erase rewrites the whole contact, so that call must also carry every other optional field, or it is refused."
11881
11962
  },
11882
11963
  email: {
11883
11964
  type: ["string", "null"],
11884
- description: "Contact email. Pass null to clear it."
11965
+ description: "Contact email. Omit it to leave it unchanged. Pass null to ERASE it \u2014 an erase rewrites the whole contact, so that call must also carry every other optional field, or it is refused."
11885
11966
  },
11886
11967
  phone_number: {
11887
11968
  type: ["string", "null"],
11888
- description: "Contact phone (free-form). Pass null to clear it."
11969
+ description: "Contact phone (free-form). Omit it to leave it unchanged. Pass null to ERASE it \u2014 an erase rewrites the whole contact, so that call must also carry every other optional field, or it is refused."
11889
11970
  }
11890
11971
  },
11891
11972
  required: ["contact_id", "first_name", "last_name"],
11892
11973
  additionalProperties: false
11893
11974
  },
11894
11975
  execute: async (client, params, _ctx) => {
11976
+ const OPTIONAL = ["job_title", "linkedin_page", "email", "phone_number"];
11977
+ const asked = (f) => params[f] !== void 0;
11978
+ const clearing = OPTIONAL.filter((f) => params[f] === null);
11895
11979
  const body = {
11896
11980
  first_name: params.first_name,
11897
11981
  last_name: params.last_name
11898
11982
  };
11899
- if (params.job_title !== void 0)
11900
- body.job_title = params.job_title;
11901
- if (params.linkedin_page !== void 0)
11902
- body.linkedin_page = params.linkedin_page;
11903
- if (params.email !== void 0)
11904
- body.email = params.email;
11905
- if (params.phone_number !== void 0)
11906
- body.phone_number = params.phone_number;
11983
+ for (const f of OPTIONAL)
11984
+ if (asked(f))
11985
+ body[f] = params[f];
11986
+ if (clearing.length === 0) {
11987
+ const contact2 = await client.request("POST", `/contacts/${params.contact_id}/merge`, body);
11988
+ return {
11989
+ updated: true,
11990
+ contact_id: params.contact_id,
11991
+ contact: contact2,
11992
+ mode: "merge",
11993
+ preserved: OPTIONAL.filter((f) => !asked(f)),
11994
+ cleared: []
11995
+ };
11996
+ }
11997
+ const missing = OPTIONAL.filter((f) => !asked(f));
11998
+ if (missing.length > 0) {
11999
+ throw client.makeError("CONTACT_CLEAR_NEEDS_FULL_RECORD", `Clearing ${clearing.join(", ")} rewrites the whole contact, and ${missing.join(", ")} ${missing.length === 1 ? "was" : "were"} not supplied`, `Read the contact (leadbay_research_lead_by_id) and re-call with ALL of ${OPTIONAL.join(", ")} \u2014 current value to keep it, null to clear it. Omitting a field here would delete it.`, `POST /contacts/${params.contact_id}/update`);
12000
+ }
11907
12001
  const contact = await client.request("POST", `/contacts/${params.contact_id}/update`, body);
11908
- return { updated: true, contact_id: params.contact_id, contact };
12002
+ return {
12003
+ updated: true,
12004
+ contact_id: params.contact_id,
12005
+ contact,
12006
+ mode: "replace",
12007
+ preserved: [],
12008
+ cleared: clearing
12009
+ };
11909
12010
  }
11910
12011
  };
11911
12012
  }
@@ -13926,7 +14027,8 @@ ${firm.short_description}`);
13926
14027
  const ln = c.last_name ?? "";
13927
14028
  const title = c.job_title ?? "\u2014";
13928
14029
  const channel = c.email ?? c.phone_number ?? "\u2014";
13929
- out.push(`- **${(fn + " " + ln).trim() || "(unknown)"}** \u2014 ${title} \xB7 ${channel}`);
14030
+ const pin = c.pinned ? " \u{1F4CC}" : "";
14031
+ out.push(`- **${(fn + " " + ln).trim() || "(unknown)"}**${pin} \u2014 ${title} \xB7 ${channel}`);
13930
14032
  }
13931
14033
  }
13932
14034
  const candidates = Array.isArray(contacts.candidates) ? contacts.candidates : [];
@@ -13938,7 +14040,8 @@ ${firm.short_description}`);
13938
14040
  const ln = c.last_name ?? "";
13939
14041
  const title = c.job_title ?? "\u2014";
13940
14042
  const li = c.linkedin_page ? `LinkedIn` : "no LinkedIn";
13941
- out.push(`- **${(fn + " " + ln).trim() || "(unknown)"}** \u2014 ${title} \xB7 ${li}`);
14043
+ const pin = c.pinned ? " \u{1F4CC}" : "";
14044
+ out.push(`- **${(fn + " " + ln).trim() || "(unknown)"}**${pin} \u2014 ${title} \xB7 ${li}`);
13942
14045
  }
13943
14046
  if (candidates.length > 10)
13944
14047
  out.push(`- _${candidates.length - 10} more \u2026_`);
@@ -14150,7 +14253,7 @@ var init_research_lead_by_id = __esm({
14150
14253
  },
14151
14254
  contacts: {
14152
14255
  type: "object",
14153
- 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.',
14256
+ 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 / leadbay_pin_contact / leadbay_unpin_contact \u2014 a `"paid"` id returns NOT_FOUND there, which means "this candidate is not an org contact yet", NOT that the tool is broken. Only `source:"org"` contacts carry `pinned` (someone flagged this person as the priority) and `pinned_by_ai` (that someone was Leadbay\'s AI, not a human); paid candidates have no pin state because they cannot be pinned.',
14154
14257
  properties: {
14155
14258
  reachable: { type: "array", items: { type: "object" } },
14156
14259
  candidates: { type: "array", items: { type: "object" } }
@@ -14275,6 +14378,12 @@ var init_research_lead_by_id = __esm({
14275
14378
  linkedin_page: normalizeLinkedinPage5(c.linkedin_page ?? null),
14276
14379
  recommended: c.recommended,
14277
14380
  enrichment_done: true,
14381
+ // Pin state exists on org contacts only — mirror the backend, which
14382
+ // omits it entirely from PaidContactPayload. `pinned` is what makes a
14383
+ // pin readable at all; without it the agent can only infer the pin from
14384
+ // `recommended`, which also moves for non-pin reasons.
14385
+ pinned: c.pinned ?? false,
14386
+ pinned_by_ai: c.pinned_by_ai ?? false,
14278
14387
  source: "org"
14279
14388
  });
14280
14389
  const allContacts = [
@@ -27862,7 +27971,7 @@ var OAUTH_BASE_URLS = {
27862
27971
  fr: "https://staging.api.leadbay.app"
27863
27972
  }
27864
27973
  };
27865
- var VERSION = "0.33.1";
27974
+ var VERSION = "0.33.3";
27866
27975
  var HELP = `
27867
27976
  leadbay-mcp ${VERSION} \u2014 Leadbay Model Context Protocol server
27868
27977
 
@@ -6279,7 +6279,7 @@ Trigger phrases: "pin this contact", "mark this person as priority", "make this
6279
6279
 
6280
6280
  Do NOT use for: "unpin / remove the pin" \u2192 \`leadbay_unpin_contact\`; "add a contact to this company" \u2192 \`leadbay_add_contact\`; "remove / delete this contact" \u2192 \`leadbay_remove_contact\`.
6281
6281
 
6282
- Prefer when: user wants ONE person flagged as the priority on a company \u2014 pass that contact's own \`contact_id\`
6282
+ Prefer when: user wants ONE person flagged as the priority on a company \u2014 pass that contact's own \`contact_id\`, and ONLY a \`source:"org"\` contact can be pinned (a \`source:"paid"\` candidate returns 'contact not found')
6283
6283
 
6284
6284
  Examples that SHOULD invoke this tool:
6285
6285
  - "Pin Jane Doe as the main contact on this company."
@@ -6301,9 +6301,24 @@ Pin a single contact on a company so it surfaces first as a priority / favourite
6301
6301
 
6302
6302
  Pass the contact's **own** \`contact_id\` (the \`id\` field on a contact object from \`leadbay_research_lead_by_id\` or a contacts list) \u2014 **not** the parent lead id.
6303
6303
 
6304
+ **Only \`source: "org"\` contacts are pinnable.** Every contact returned by \`leadbay_research_lead_by_id\` carries a \`source\` field, and the two sources are separate id namespaces on the backend:
6305
+
6306
+ - \`source: "org"\` \u2014 a row in your organization's own contact directory. Pinnable. Also carries \`pinned\` (true when someone has pinned it) and \`pinned_by_ai\` (true when Leadbay's AI pinned it rather than a human).
6307
+ - \`source: "paid"\` \u2014 an enrichment *candidate* (the \`candidates\` bucket): a person Leadbay suggests but has not yet resolved into your directory. NOT pinnable, and carries no \`pinned\` field at all.
6308
+
6309
+ Passing a \`source: "paid"\` id here returns **\`contact not found\`**. That is the expected answer for a candidate, not an outage and not a transient error: nothing is broken, the person is simply not an org contact yet. Do not retry, do not re-fetch the lead hoping for a different result, and do not tell the user that pinning is failing or unavailable.
6310
+
6311
+ To pin someone who is currently only a candidate, first make them an org contact:
6312
+
6313
+ - \`leadbay_enrich_titles\` (or \`leadbay_prepare_outreach\` with \`enrich: true\`) resolves the candidate and writes a NEW org contact for that person. It has a **different \`id\`** from the paid candidate, so re-read the contacts list afterwards and pin the \`source: "org"\` row.
6314
+ - Or add them directly with \`leadbay_add_contact\`, which returns the new org contact's \`id\` \u2014 that id is pinnable immediately.
6315
+
6316
+ **Pinning does not steer enrichment.** It only marks who the priority is on a company the user already has. Enrichment picks people by JOB TITLE, so "enrich the Directeur G\xE9n\xE9ral rather than the Pr\xE9sident" is \`leadbay_enrich_titles\` with the wanted title \u2014 not a pin. Pinning first and enriching after changes nothing about who gets enriched.
6317
+
6318
+
6304
6319
  Backend: \`POST /contacts/{contact_id}/pin\` \u2192 204. Idempotent. The inverse is \`leadbay_unpin_contact\`.
6305
6320
 
6306
- Returns \`{ pinned: true, contact_id, action: "pinned" }\`.
6321
+ Returns \`{ pinned: true, contact_id, action: "pinned" }\`. To read the resulting state back, re-call \`leadbay_research_lead_by_id\` \u2014 the pinned contact's \`pinned\` flips to \`true\` and it becomes the lead's \`recommended\` contact.
6307
6322
 
6308
6323
  Requires: LEADBAY_MCP_WRITE=1 (MCP) or exposeWrite=true (OpenClaw).
6309
6324
  `;
@@ -8248,7 +8263,7 @@ Trigger phrases: "unpin this contact", "remove the pin from this contact", "this
8248
8263
 
8249
8264
  Do NOT use for: "pin / mark as priority" \u2192 \`leadbay_pin_contact\`; "remove / delete this contact" \u2192 \`leadbay_remove_contact\`.
8250
8265
 
8251
- Prefer when: user wants to clear the pinned flag on a contact (but keep the contact) \u2014 pass that contact's own \`contact_id\`
8266
+ Prefer when: user wants to clear the pinned flag on a contact (but keep the contact) \u2014 pass that contact's own \`contact_id\`, and ONLY a \`source:"org"\` contact can be unpinned (a \`source:"paid"\` candidate returns 'contact not found')
8252
8267
 
8253
8268
  Examples that SHOULD invoke this tool:
8254
8269
  - "Unpin Jane Doe \u2014 she's not the priority anymore."
@@ -8271,9 +8286,26 @@ Unpin a single contact on a company \u2014 clears its priority / favourite flag.
8271
8286
 
8272
8287
  Pass the contact's **own** \`contact_id\` \u2014 not the parent lead id.
8273
8288
 
8289
+ **Only \`source: "org"\` contacts are pinnable.** Every contact returned by \`leadbay_research_lead_by_id\` carries a \`source\` field, and the two sources are separate id namespaces on the backend:
8290
+
8291
+ - \`source: "org"\` \u2014 a row in your organization's own contact directory. Pinnable. Also carries \`pinned\` (true when someone has pinned it) and \`pinned_by_ai\` (true when Leadbay's AI pinned it rather than a human).
8292
+ - \`source: "paid"\` \u2014 an enrichment *candidate* (the \`candidates\` bucket): a person Leadbay suggests but has not yet resolved into your directory. NOT pinnable, and carries no \`pinned\` field at all.
8293
+
8294
+ Passing a \`source: "paid"\` id here returns **\`contact not found\`**. That is the expected answer for a candidate, not an outage and not a transient error: nothing is broken, the person is simply not an org contact yet. Do not retry, do not re-fetch the lead hoping for a different result, and do not tell the user that pinning is failing or unavailable.
8295
+
8296
+ To pin someone who is currently only a candidate, first make them an org contact:
8297
+
8298
+ - \`leadbay_enrich_titles\` (or \`leadbay_prepare_outreach\` with \`enrich: true\`) resolves the candidate and writes a NEW org contact for that person. It has a **different \`id\`** from the paid candidate, so re-read the contacts list afterwards and pin the \`source: "org"\` row.
8299
+ - Or add them directly with \`leadbay_add_contact\`, which returns the new org contact's \`id\` \u2014 that id is pinnable immediately.
8300
+
8301
+ **Pinning does not steer enrichment.** It only marks who the priority is on a company the user already has. Enrichment picks people by JOB TITLE, so "enrich the Directeur G\xE9n\xE9ral rather than the Pr\xE9sident" is \`leadbay_enrich_titles\` with the wanted title \u2014 not a pin. Pinning first and enriching after changes nothing about who gets enriched.
8302
+
8303
+
8304
+ A \`source: "org"\` contact that was never pinned is a no-op here, not an error \u2014 the backend answers 204 either way. Check \`pinned\` on the contact before calling if you need to tell the user whether anything actually changed.
8305
+
8274
8306
  Backend: \`POST /contacts/{contact_id}/unpin\` \u2192 204. Idempotent. The inverse is \`leadbay_pin_contact\`.
8275
8307
 
8276
- Returns \`{ pinned: false, contact_id, action: "unpinned" }\`.
8308
+ Returns \`{ pinned: false, contact_id, action: "unpinned" }\`. To read the resulting state back, re-call \`leadbay_research_lead_by_id\` \u2014 the contact's \`pinned\` flips to \`false\` and the lead's \`recommended\` contact reverts to the title-matched default.
8277
8309
 
8278
8310
  Requires: LEADBAY_MCP_WRITE=1 (MCP) or exposeWrite=true (OpenClaw).
8279
8311
  `;
@@ -8283,7 +8315,7 @@ Trigger phrases: "update this contact", "fix this contact's title", "change thei
8283
8315
 
8284
8316
  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\`.
8285
8317
 
8286
- 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
8318
+ Prefer when: user wants to change details on a contact that is in their own directory (\`source: "org"\`) \u2014 pass its \`contact_id\`, first_name, last_name and only the fields being changed
8287
8319
 
8288
8320
  Examples that SHOULD invoke this tool:
8289
8321
  - "Update Jane's title to SVP Engineering."
@@ -8316,11 +8348,30 @@ Pass the contact's **own** \`contact_id\` \u2014 **not** the parent lead id.
8316
8348
 
8317
8349
  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.
8318
8350
 
8319
- **\`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.
8351
+ ## Omitting a field keeps it. Erasing one takes an explicit \`null\`.
8352
+
8353
+ Send only what you are changing. Any field you leave out keeps its current value \u2014 you do NOT need to read the contact first and echo everything back.
8354
+
8355
+ \`\`\`
8356
+ { contact_id, first_name, last_name, job_title: "CEO" }
8357
+ \u2192 title becomes CEO. email, phone and LinkedIn are untouched.
8358
+ \`\`\`
8359
+
8360
+ **To erase a field, pass it as \`null\`.** Because erasing rewrites the whole record, that call must carry ALL of \`job_title\`, \`linkedin_page\`, \`email\`, \`phone_number\` \u2014 current value for the ones to keep, \`null\` for the ones to erase. If any are missing the call is refused with \`CONTACT_CLEAR_NEEDS_FULL_RECORD\` rather than deleting them; read the contact with \`leadbay_research_lead_by_id\` and re-call.
8361
+
8362
+ \`\`\`
8363
+ { contact_id, first_name, last_name, email: null,
8364
+ job_title: "CEO", phone_number: "+33\u2026", linkedin_page: "https://\u2026" }
8365
+ \u2192 email erased, everything else as given.
8366
+ \`\`\`
8367
+
8368
+ \`first_name\` + \`last_name\` are required on every call. The backend validates the contact's identity and rejects a body without them (\`invalid contact\`), so pass the current values when you are not changing the name.
8369
+
8370
+ The result tells you which happened: \`mode\` is \`merge\` or \`replace\`, \`preserved\` lists the fields left untouched, \`cleared\` lists the fields erased. **Check \`cleared\` is what you intended.**
8320
8371
 
8321
- Backend: \`POST /contacts/{contact_id}/update\` (snake_case body) \u2192 200 with the updated contact. Edits in place (same id). Camel-case bodies are rejected.
8372
+ Backend: \`POST /contacts/{contact_id}/merge\` when nothing is being erased, \`POST /contacts/{contact_id}/update\` when something is. Snake_case body; camel-case is rejected. Edits in place (same id).
8322
8373
 
8323
- Returns \`{ updated: true, contact_id, contact: { id, first_name, last_name, job_title, linkedin_page, email, phone_number } }\`.
8374
+ Returns \`{ updated: true, contact_id, mode, preserved, cleared, contact: { id, first_name, last_name, job_title, linkedin_page, email, phone_number } }\`.
8324
8375
 
8325
8376
  Requires: LEADBAY_MCP_WRITE=1 (MCP) or exposeWrite=true (OpenClaw).
8326
8377
  `;
@@ -8629,6 +8680,10 @@ var getLeadProfile = {
8629
8680
  job_title: c.job_title,
8630
8681
  recommended: c.recommended,
8631
8682
  enrichment: c.enrichment,
8683
+ // Org contacts only — the backend's PaidContactPayload carries no pin
8684
+ // state, because a paid candidate cannot be pinned.
8685
+ pinned: c.pinned ?? false,
8686
+ pinned_by_ai: c.pinned_by_ai ?? false,
8632
8687
  source: "org"
8633
8688
  })),
8634
8689
  ...paidContacts.map((c) => ({
@@ -8713,7 +8768,7 @@ var getContacts = {
8713
8768
  properties: {
8714
8769
  contacts: {
8715
8770
  type: "array",
8716
- description: "Merged org+paid contacts. Each: {id, first_name, last_name, email, phone_number, linkedin_page, job_title, recommended, enrichment, source:'org'|'paid'}.",
8771
+ description: "Merged org+paid contacts. Each: {id, first_name, last_name, email, phone_number, linkedin_page, job_title, recommended, enrichment, source:'org'|'paid'}. `source:'org'` entries additionally carry {pinned, pinned_by_ai}; `source:'paid'` entries do not, because a paid candidate cannot be pinned \u2014 passing its id to leadbay_pin_contact / leadbay_unpin_contact returns NOT_FOUND.",
8717
8772
  items: {
8718
8773
  type: "object",
8719
8774
  properties: {
@@ -8725,6 +8780,14 @@ var getContacts = {
8725
8780
  linkedin_page: { type: ["string", "null"] },
8726
8781
  job_title: { type: ["string", "null"] },
8727
8782
  recommended: { type: "boolean" },
8783
+ pinned: {
8784
+ type: "boolean",
8785
+ description: "Someone flagged this person as the priority on the company. Present on source:'org' contacts only."
8786
+ },
8787
+ pinned_by_ai: {
8788
+ type: "boolean",
8789
+ description: "The pin came from Leadbay's AI rather than a human. Present on source:'org' contacts only."
8790
+ },
8728
8791
  source: { type: "string", enum: ["org", "paid"] },
8729
8792
  enrichment: {
8730
8793
  type: ["object", "null"],
@@ -8783,6 +8846,10 @@ var getContacts = {
8783
8846
  job_title: c.job_title,
8784
8847
  recommended: c.recommended,
8785
8848
  enrichment: c.enrichment,
8849
+ // Org contacts only — the backend's PaidContactPayload carries no
8850
+ // pin state, because a paid candidate cannot be pinned.
8851
+ pinned: c.pinned ?? false,
8852
+ pinned_by_ai: c.pinned_by_ai ?? false,
8786
8853
  source: "org"
8787
8854
  })),
8788
8855
  ...paidContacts.map((c) => ({
@@ -13912,6 +13979,7 @@ var removeContact = {
13912
13979
  };
13913
13980
 
13914
13981
  // ../core/dist/tools/pin-contact.js
13982
+ var NOT_PINNABLE_HINT = 'This contact id is not in your organization\'s contact directory, so it cannot be pinned or unpinned. Almost always it is a `source: "paid"` enrichment candidate from leadbay_research_lead_by_id\'s `candidates` list; only `source: "org"` contacts are pinnable. The id is not wrong and the tool is not broken, so do NOT retry it. To act on this person, enrich them by job title with leadbay_enrich_titles, or add them with leadbay_add_contact \u2014 either produces a NEW org contact with a different id, which is pinnable. Note that pinning does not decide who gets enriched; enrichment selects people by job title.';
13915
13983
  var pinContact = {
13916
13984
  name: "leadbay_pin_contact",
13917
13985
  description: leadbay_pin_contact,
@@ -13935,7 +14003,13 @@ var pinContact = {
13935
14003
  additionalProperties: false
13936
14004
  },
13937
14005
  execute: async (client, params, _ctx) => {
13938
- await client.requestVoid("POST", `/contacts/${params.contact_id}/pin`);
14006
+ try {
14007
+ await client.requestVoid("POST", `/contacts/${params.contact_id}/pin`);
14008
+ } catch (e) {
14009
+ if (e?.code === "NOT_FOUND")
14010
+ throw { ...e, hint: NOT_PINNABLE_HINT };
14011
+ throw e;
14012
+ }
13939
14013
  return { pinned: true, contact_id: params.contact_id, action: "pinned" };
13940
14014
  }
13941
14015
  };
@@ -13964,7 +14038,13 @@ var unpinContact = {
13964
14038
  additionalProperties: false
13965
14039
  },
13966
14040
  execute: async (client, params, _ctx) => {
13967
- await client.requestVoid("POST", `/contacts/${params.contact_id}/unpin`);
14041
+ try {
14042
+ await client.requestVoid("POST", `/contacts/${params.contact_id}/unpin`);
14043
+ } catch (e) {
14044
+ if (e?.code === "NOT_FOUND")
14045
+ throw { ...e, hint: NOT_PINNABLE_HINT };
14046
+ throw e;
14047
+ }
13968
14048
  return { pinned: false, contact_id: params.contact_id, action: "unpinned" };
13969
14049
  }
13970
14050
  };
@@ -14000,39 +14080,59 @@ var updateContact = {
14000
14080
  // new value. execute forwards null verbatim; the backend accepts it.
14001
14081
  job_title: {
14002
14082
  type: ["string", "null"],
14003
- description: "Contact job title. Pass null to clear it."
14083
+ description: "Contact job title. Omit it to leave it unchanged. Pass null to ERASE it \u2014 an erase rewrites the whole contact, so that call must also carry every other optional field, or it is refused."
14004
14084
  },
14005
14085
  linkedin_page: {
14006
14086
  type: ["string", "null"],
14007
- description: "Contact LinkedIn URL. Pass null to clear it."
14087
+ description: "Contact LinkedIn URL. Omit it to leave it unchanged. Pass null to ERASE it \u2014 an erase rewrites the whole contact, so that call must also carry every other optional field, or it is refused."
14008
14088
  },
14009
14089
  email: {
14010
14090
  type: ["string", "null"],
14011
- description: "Contact email. Pass null to clear it."
14091
+ description: "Contact email. Omit it to leave it unchanged. Pass null to ERASE it \u2014 an erase rewrites the whole contact, so that call must also carry every other optional field, or it is refused."
14012
14092
  },
14013
14093
  phone_number: {
14014
14094
  type: ["string", "null"],
14015
- description: "Contact phone (free-form). Pass null to clear it."
14095
+ description: "Contact phone (free-form). Omit it to leave it unchanged. Pass null to ERASE it \u2014 an erase rewrites the whole contact, so that call must also carry every other optional field, or it is refused."
14016
14096
  }
14017
14097
  },
14018
14098
  required: ["contact_id", "first_name", "last_name"],
14019
14099
  additionalProperties: false
14020
14100
  },
14021
14101
  execute: async (client, params, _ctx) => {
14102
+ const OPTIONAL = ["job_title", "linkedin_page", "email", "phone_number"];
14103
+ const asked = (f) => params[f] !== void 0;
14104
+ const clearing = OPTIONAL.filter((f) => params[f] === null);
14022
14105
  const body = {
14023
14106
  first_name: params.first_name,
14024
14107
  last_name: params.last_name
14025
14108
  };
14026
- if (params.job_title !== void 0)
14027
- body.job_title = params.job_title;
14028
- if (params.linkedin_page !== void 0)
14029
- body.linkedin_page = params.linkedin_page;
14030
- if (params.email !== void 0)
14031
- body.email = params.email;
14032
- if (params.phone_number !== void 0)
14033
- body.phone_number = params.phone_number;
14109
+ for (const f of OPTIONAL)
14110
+ if (asked(f))
14111
+ body[f] = params[f];
14112
+ if (clearing.length === 0) {
14113
+ const contact2 = await client.request("POST", `/contacts/${params.contact_id}/merge`, body);
14114
+ return {
14115
+ updated: true,
14116
+ contact_id: params.contact_id,
14117
+ contact: contact2,
14118
+ mode: "merge",
14119
+ preserved: OPTIONAL.filter((f) => !asked(f)),
14120
+ cleared: []
14121
+ };
14122
+ }
14123
+ const missing = OPTIONAL.filter((f) => !asked(f));
14124
+ if (missing.length > 0) {
14125
+ throw client.makeError("CONTACT_CLEAR_NEEDS_FULL_RECORD", `Clearing ${clearing.join(", ")} rewrites the whole contact, and ${missing.join(", ")} ${missing.length === 1 ? "was" : "were"} not supplied`, `Read the contact (leadbay_research_lead_by_id) and re-call with ALL of ${OPTIONAL.join(", ")} \u2014 current value to keep it, null to clear it. Omitting a field here would delete it.`, `POST /contacts/${params.contact_id}/update`);
14126
+ }
14034
14127
  const contact = await client.request("POST", `/contacts/${params.contact_id}/update`, body);
14035
- return { updated: true, contact_id: params.contact_id, contact };
14128
+ return {
14129
+ updated: true,
14130
+ contact_id: params.contact_id,
14131
+ contact,
14132
+ mode: "replace",
14133
+ preserved: [],
14134
+ cleared: clearing
14135
+ };
14036
14136
  }
14037
14137
  };
14038
14138
 
@@ -15938,7 +16038,8 @@ ${firm.short_description}`);
15938
16038
  const ln = c.last_name ?? "";
15939
16039
  const title = c.job_title ?? "\u2014";
15940
16040
  const channel = c.email ?? c.phone_number ?? "\u2014";
15941
- out.push(`- **${(fn + " " + ln).trim() || "(unknown)"}** \u2014 ${title} \xB7 ${channel}`);
16041
+ const pin = c.pinned ? " \u{1F4CC}" : "";
16042
+ out.push(`- **${(fn + " " + ln).trim() || "(unknown)"}**${pin} \u2014 ${title} \xB7 ${channel}`);
15942
16043
  }
15943
16044
  }
15944
16045
  const candidates = Array.isArray(contacts.candidates) ? contacts.candidates : [];
@@ -15950,7 +16051,8 @@ ${firm.short_description}`);
15950
16051
  const ln = c.last_name ?? "";
15951
16052
  const title = c.job_title ?? "\u2014";
15952
16053
  const li = c.linkedin_page ? `LinkedIn` : "no LinkedIn";
15953
- out.push(`- **${(fn + " " + ln).trim() || "(unknown)"}** \u2014 ${title} \xB7 ${li}`);
16054
+ const pin = c.pinned ? " \u{1F4CC}" : "";
16055
+ out.push(`- **${(fn + " " + ln).trim() || "(unknown)"}**${pin} \u2014 ${title} \xB7 ${li}`);
15954
16056
  }
15955
16057
  if (candidates.length > 10)
15956
16058
  out.push(`- _${candidates.length - 10} more \u2026_`);
@@ -16156,7 +16258,7 @@ var researchLeadById = {
16156
16258
  },
16157
16259
  contacts: {
16158
16260
  type: "object",
16159
- 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.',
16261
+ 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 / leadbay_pin_contact / leadbay_unpin_contact \u2014 a `"paid"` id returns NOT_FOUND there, which means "this candidate is not an org contact yet", NOT that the tool is broken. Only `source:"org"` contacts carry `pinned` (someone flagged this person as the priority) and `pinned_by_ai` (that someone was Leadbay\'s AI, not a human); paid candidates have no pin state because they cannot be pinned.',
16160
16262
  properties: {
16161
16263
  reachable: { type: "array", items: { type: "object" } },
16162
16264
  candidates: { type: "array", items: { type: "object" } }
@@ -16281,6 +16383,12 @@ var researchLeadById = {
16281
16383
  linkedin_page: normalizeLinkedinPage5(c.linkedin_page ?? null),
16282
16384
  recommended: c.recommended,
16283
16385
  enrichment_done: true,
16386
+ // Pin state exists on org contacts only — mirror the backend, which
16387
+ // omits it entirely from PaidContactPayload. `pinned` is what makes a
16388
+ // pin readable at all; without it the agent can only infer the pin from
16389
+ // `recommended`, which also moves for non-pin reasons.
16390
+ pinned: c.pinned ?? false,
16391
+ pinned_by_ai: c.pinned_by_ai ?? false,
16284
16392
  source: "org"
16285
16393
  });
16286
16394
  const allContacts = [
@@ -24629,7 +24737,7 @@ function parseWriteEnv(env = process.env) {
24629
24737
  }
24630
24738
 
24631
24739
  // src/http-server.ts
24632
- var VERSION = true ? "0.33.1" : "0.0.0-dev";
24740
+ var VERSION = true ? "0.33.3" : "0.0.0-dev";
24633
24741
  var PORT = Number(process.env.PORT ?? 8080);
24634
24742
  var HOST = process.env.HOST ?? "0.0.0.0";
24635
24743
  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.33.1" : "0.0.0-dev";
1807
+ VERSION = true ? "0.33.3" : "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.33.1" : "0.0.0-dev";
1070
+ var VERSION = true ? "0.33.3" : "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.33.1",
3
+ "version": "0.33.3",
4
4
  "mcpName": "io.github.leadbay/leadbay-mcp",
5
5
  "description": "Model Context Protocol (MCP) server for Leadbay — AI lead discovery, qualification, and enrichment for Claude Desktop, Cursor, and Claude Code.",
6
6
  "type": "module",