@leadbay/mcp 0.33.1 → 0.33.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,55 @@
1
1
  # Changelog — @leadbay/mcp
2
2
 
3
+ ## 0.33.2 — 2026-09-02
4
+
5
+ Editing one field on a contact erased the others (product#4046).
6
+
7
+ Reproduced on production while verifying leadbay/mcp#194: sending a contact's
8
+ own current `first_name` + `last_name` + `job_title`, changing nothing, deleted
9
+ that contact's email. The contact moved from `contacts.reachable` to
10
+ `contacts.candidates` and `_meta.has_reachable_contact` flipped to false, so the
11
+ lead stopped being contactable. Restored with a second call.
12
+
13
+ This became reachable only the day before: until #194 the tool returned 404 on
14
+ 100% of calls, so it destroyed nothing. A tool that always failed now succeeded
15
+ and deleted data.
16
+
17
+ **The backend already had the safe behaviour and we were choosing the other
18
+ one.** Both routes take the same payload
19
+ (`OrgContactRoutes.kt:110,152` → `OrgContactsDaoImpl.kt:265-272`):
20
+
21
+ | Route | `forceUpdateIfNullOrEmpty` | A field absent from the body |
22
+ |---|---|---|
23
+ | `/contacts/{id}/update` | `true` | written as null — **erased** |
24
+ | `/contacts/{id}/merge` | `false` | skipped — **kept** |
25
+
26
+ So the fix needs no read-modify-write, no extra round-trip and no race window.
27
+ `leadbay_update_contact` now routes by intent:
28
+
29
+ - **Nothing being erased → `/merge`.** Every field the caller omitted survives.
30
+ - **Any field passed as `null` → `/update`**, which rewrites the record — so
31
+ that call must carry all four optional fields. If it does not, the tool
32
+ refuses with `CONTACT_CLEAR_NEEDS_FULL_RECORD` rather than deleting what was
33
+ not mentioned. The destructive path costs more effort than the safe one,
34
+ which is the intended asymmetry.
35
+ - The result carries `mode` (`merge` / `replace`), `preserved` and `cleared`, so
36
+ a wrong edit is visible rather than silent.
37
+
38
+ Verified live on staging, same body to each route:
39
+
40
+ ```
41
+ POST /contacts/{id}/merge {first_name, last_name, job_title:"CEO"}
42
+ → job_title CEO, email merge.probe@example.test, phone +15550001111 (kept)
43
+ POST /contacts/{id}/update {first_name, last_name, job_title:"CTO"}
44
+ → job_title CTO, email absent, phone absent (erased)
45
+ ```
46
+
47
+ Two existing test files were changed rather than added, because both asserted
48
+ the contract this fixes: `update-contact.test.ts`'s happy path now expects
49
+ `/merge`, and `update-contact-null-clear.test.ts`'s clear case now supplies the
50
+ whole record. The second had encoded the data loss as correct — it asserted that
51
+ unmentioned fields are not sent, while posting to the route that deletes them.
52
+
3
53
  ## 0.33.1 — 2026-09-02
4
54
 
5
55
  Follow-up to 0.32.0 (product#4007). A review finding landed after the merge.
package/dist/bin.js CHANGED
@@ -5692,7 +5692,7 @@ Trigger phrases: "update this contact", "fix this contact's title", "change thei
5692
5692
 
5693
5693
  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
5694
 
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
5695
+ 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
5696
 
5697
5697
  Examples that SHOULD invoke this tool:
5698
5698
  - "Update Jane's title to SVP Engineering."
@@ -5725,11 +5725,30 @@ Pass the contact's **own** \`contact_id\` \u2014 **not** the parent lead id.
5725
5725
 
5726
5726
  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
5727
 
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.
5728
+ ## Omitting a field keeps it. Erasing one takes an explicit \`null\`.
5729
5729
 
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.
5730
+ 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.
5731
5731
 
5732
- Returns \`{ updated: true, contact_id, contact: { id, first_name, last_name, job_title, linkedin_page, email, phone_number } }\`.
5732
+ \`\`\`
5733
+ { contact_id, first_name, last_name, job_title: "CEO" }
5734
+ \u2192 title becomes CEO. email, phone and LinkedIn are untouched.
5735
+ \`\`\`
5736
+
5737
+ **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.
5738
+
5739
+ \`\`\`
5740
+ { contact_id, first_name, last_name, email: null,
5741
+ job_title: "CEO", phone_number: "+33\u2026", linkedin_page: "https://\u2026" }
5742
+ \u2192 email erased, everything else as given.
5743
+ \`\`\`
5744
+
5745
+ \`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.
5746
+
5747
+ 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.**
5748
+
5749
+ 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).
5750
+
5751
+ Returns \`{ updated: true, contact_id, mode, preserved, cleared, contact: { id, first_name, last_name, job_title, linkedin_page, email, phone_number } }\`.
5733
5752
 
5734
5753
  Requires: LEADBAY_MCP_WRITE=1 (MCP) or exposeWrite=true (OpenClaw).
5735
5754
  `;
@@ -11873,39 +11892,59 @@ var init_update_contact = __esm({
11873
11892
  // new value. execute forwards null verbatim; the backend accepts it.
11874
11893
  job_title: {
11875
11894
  type: ["string", "null"],
11876
- description: "Contact job title. Pass null to clear it."
11895
+ 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
11896
  },
11878
11897
  linkedin_page: {
11879
11898
  type: ["string", "null"],
11880
- description: "Contact LinkedIn URL. Pass null to clear it."
11899
+ 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
11900
  },
11882
11901
  email: {
11883
11902
  type: ["string", "null"],
11884
- description: "Contact email. Pass null to clear it."
11903
+ 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
11904
  },
11886
11905
  phone_number: {
11887
11906
  type: ["string", "null"],
11888
- description: "Contact phone (free-form). Pass null to clear it."
11907
+ 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
11908
  }
11890
11909
  },
11891
11910
  required: ["contact_id", "first_name", "last_name"],
11892
11911
  additionalProperties: false
11893
11912
  },
11894
11913
  execute: async (client, params, _ctx) => {
11914
+ const OPTIONAL = ["job_title", "linkedin_page", "email", "phone_number"];
11915
+ const asked = (f) => params[f] !== void 0;
11916
+ const clearing = OPTIONAL.filter((f) => params[f] === null);
11895
11917
  const body = {
11896
11918
  first_name: params.first_name,
11897
11919
  last_name: params.last_name
11898
11920
  };
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;
11921
+ for (const f of OPTIONAL)
11922
+ if (asked(f))
11923
+ body[f] = params[f];
11924
+ if (clearing.length === 0) {
11925
+ const contact2 = await client.request("POST", `/contacts/${params.contact_id}/merge`, body);
11926
+ return {
11927
+ updated: true,
11928
+ contact_id: params.contact_id,
11929
+ contact: contact2,
11930
+ mode: "merge",
11931
+ preserved: OPTIONAL.filter((f) => !asked(f)),
11932
+ cleared: []
11933
+ };
11934
+ }
11935
+ const missing = OPTIONAL.filter((f) => !asked(f));
11936
+ if (missing.length > 0) {
11937
+ 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`);
11938
+ }
11907
11939
  const contact = await client.request("POST", `/contacts/${params.contact_id}/update`, body);
11908
- return { updated: true, contact_id: params.contact_id, contact };
11940
+ return {
11941
+ updated: true,
11942
+ contact_id: params.contact_id,
11943
+ contact,
11944
+ mode: "replace",
11945
+ preserved: [],
11946
+ cleared: clearing
11947
+ };
11909
11948
  }
11910
11949
  };
11911
11950
  }
@@ -27862,7 +27901,7 @@ var OAUTH_BASE_URLS = {
27862
27901
  fr: "https://staging.api.leadbay.app"
27863
27902
  }
27864
27903
  };
27865
- var VERSION = "0.33.1";
27904
+ var VERSION = "0.33.2";
27866
27905
  var HELP = `
27867
27906
  leadbay-mcp ${VERSION} \u2014 Leadbay Model Context Protocol server
27868
27907
 
@@ -8283,7 +8283,7 @@ Trigger phrases: "update this contact", "fix this contact's title", "change thei
8283
8283
 
8284
8284
  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
8285
 
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
8286
+ 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
8287
 
8288
8288
  Examples that SHOULD invoke this tool:
8289
8289
  - "Update Jane's title to SVP Engineering."
@@ -8316,11 +8316,30 @@ Pass the contact's **own** \`contact_id\` \u2014 **not** the parent lead id.
8316
8316
 
8317
8317
  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
8318
 
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.
8319
+ ## Omitting a field keeps it. Erasing one takes an explicit \`null\`.
8320
8320
 
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.
8321
+ 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.
8322
8322
 
8323
- Returns \`{ updated: true, contact_id, contact: { id, first_name, last_name, job_title, linkedin_page, email, phone_number } }\`.
8323
+ \`\`\`
8324
+ { contact_id, first_name, last_name, job_title: "CEO" }
8325
+ \u2192 title becomes CEO. email, phone and LinkedIn are untouched.
8326
+ \`\`\`
8327
+
8328
+ **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.
8329
+
8330
+ \`\`\`
8331
+ { contact_id, first_name, last_name, email: null,
8332
+ job_title: "CEO", phone_number: "+33\u2026", linkedin_page: "https://\u2026" }
8333
+ \u2192 email erased, everything else as given.
8334
+ \`\`\`
8335
+
8336
+ \`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.
8337
+
8338
+ 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.**
8339
+
8340
+ 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).
8341
+
8342
+ Returns \`{ updated: true, contact_id, mode, preserved, cleared, contact: { id, first_name, last_name, job_title, linkedin_page, email, phone_number } }\`.
8324
8343
 
8325
8344
  Requires: LEADBAY_MCP_WRITE=1 (MCP) or exposeWrite=true (OpenClaw).
8326
8345
  `;
@@ -14000,39 +14019,59 @@ var updateContact = {
14000
14019
  // new value. execute forwards null verbatim; the backend accepts it.
14001
14020
  job_title: {
14002
14021
  type: ["string", "null"],
14003
- description: "Contact job title. Pass null to clear it."
14022
+ 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
14023
  },
14005
14024
  linkedin_page: {
14006
14025
  type: ["string", "null"],
14007
- description: "Contact LinkedIn URL. Pass null to clear it."
14026
+ 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
14027
  },
14009
14028
  email: {
14010
14029
  type: ["string", "null"],
14011
- description: "Contact email. Pass null to clear it."
14030
+ 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
14031
  },
14013
14032
  phone_number: {
14014
14033
  type: ["string", "null"],
14015
- description: "Contact phone (free-form). Pass null to clear it."
14034
+ 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
14035
  }
14017
14036
  },
14018
14037
  required: ["contact_id", "first_name", "last_name"],
14019
14038
  additionalProperties: false
14020
14039
  },
14021
14040
  execute: async (client, params, _ctx) => {
14041
+ const OPTIONAL = ["job_title", "linkedin_page", "email", "phone_number"];
14042
+ const asked = (f) => params[f] !== void 0;
14043
+ const clearing = OPTIONAL.filter((f) => params[f] === null);
14022
14044
  const body = {
14023
14045
  first_name: params.first_name,
14024
14046
  last_name: params.last_name
14025
14047
  };
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;
14048
+ for (const f of OPTIONAL)
14049
+ if (asked(f))
14050
+ body[f] = params[f];
14051
+ if (clearing.length === 0) {
14052
+ const contact2 = await client.request("POST", `/contacts/${params.contact_id}/merge`, body);
14053
+ return {
14054
+ updated: true,
14055
+ contact_id: params.contact_id,
14056
+ contact: contact2,
14057
+ mode: "merge",
14058
+ preserved: OPTIONAL.filter((f) => !asked(f)),
14059
+ cleared: []
14060
+ };
14061
+ }
14062
+ const missing = OPTIONAL.filter((f) => !asked(f));
14063
+ if (missing.length > 0) {
14064
+ 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`);
14065
+ }
14034
14066
  const contact = await client.request("POST", `/contacts/${params.contact_id}/update`, body);
14035
- return { updated: true, contact_id: params.contact_id, contact };
14067
+ return {
14068
+ updated: true,
14069
+ contact_id: params.contact_id,
14070
+ contact,
14071
+ mode: "replace",
14072
+ preserved: [],
14073
+ cleared: clearing
14074
+ };
14036
14075
  }
14037
14076
  };
14038
14077
 
@@ -24629,7 +24668,7 @@ function parseWriteEnv(env = process.env) {
24629
24668
  }
24630
24669
 
24631
24670
  // src/http-server.ts
24632
- var VERSION = true ? "0.33.1" : "0.0.0-dev";
24671
+ var VERSION = true ? "0.33.2" : "0.0.0-dev";
24633
24672
  var PORT = Number(process.env.PORT ?? 8080);
24634
24673
  var HOST = process.env.HOST ?? "0.0.0.0";
24635
24674
  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.2" : "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.2" : "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.2",
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",