@leadbay/mcp 0.31.1 → 0.32.1

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,189 @@
1
1
  # Changelog — @leadbay/mcp
2
2
 
3
+ ## 0.32.0 — 2026-09-01
4
+
5
+ A poll-budget timeout stops being an error (product#4007). The import wizard's
6
+ phases are bimodal — ~7s or ~85s, essentially nothing in between — and
7
+ `DEFAULT_PER_PHASE_BUDGET_MS` (60s) sat squarely in the gap, so the same input
8
+ randomly succeeded or threw. One reported session: 21 successful imports and 9
9
+ timeouts on a single spreadsheet, all nine timeouts issued from one user
10
+ instruction. The agent re-issued the identical call nine times over eleven
11
+ minutes, because an error is a thing you retry. Account details are in
12
+ product#4007; this file is published to npm, so they stay there.
13
+
14
+ - **`IMPORT_BUDGET_EXHAUSTED` → `IMPORT_TIMEOUT`.** No alias. "Budget" meant
15
+ milliseconds and every reader — customer, support, engineer — parsed it as
16
+ money. There is no import billing cap. Keeping the old string alive in
17
+ telemetry would preserve the exact confusion the rename exists to end.
18
+ - **The blocking path returns `{status:"running", timed_out:true, importIds}`
19
+ instead of throwing.** `ImportPhaseTimeout` is now an internal signal raised by
20
+ the three poll sites (preprocess / process / records-terminal) and converted to
21
+ a result in `execute`. A real backend error — `IMPORT_PREPROCESS_FAILED`,
22
+ `IMPORT_PROCESSING_FAILED` — still throws; degradation is timeout-only.
23
+ - **A preprocess timeout parks the import, so it gets a detached finisher.**
24
+ Found by probing us-staging rather than by reading the code: `update_mappings`
25
+ is the MCP's *own* POST, and it only fires after preprocess. Walk away before
26
+ sending it and the wizard row sits inert forever — `pre_processing.finished`
27
+ true, `processing` absent, `total_records` 0, and `GET /records` answering
28
+ `400 in_progress` indefinitely. `summarizeImports` reads that shape as
29
+ **complete**, so without this the fix would have converted a loud error into a
30
+ silent "running… complete… no leads", which is worse than what it replaced.
31
+ `resumeParkedUpload` hands the uploaded chunk to a detached continuation that
32
+ commits the mappings, so `status:"running"` is a true statement. Process and
33
+ reconcile timeouts need nothing — the backend is already working. Verified
34
+ live on us-staging 2026-09-01: forced timeout → running at t+0, three real
35
+ leadIds out of `leadbay_import_status(importIds)` at t+10s.
36
+ - **`IMPORT_NOT_TERMINAL` is retired.** Its hint said *"Retry
37
+ leadbay_import_leads with the same input in 30s"* — our own contract asking
38
+ for the loop. That path degrades like the other two now.
39
+ - **`leadbay_import_status` returns leads on the `importIds[]` path.** It used
40
+ to carry `result` only when resolving a `handle_id` against the BulkTracker,
41
+ so an import that timed out mid-poll could be observed as `complete` and still
42
+ leave the agent with no leadIds — whose only route to them was re-running the
43
+ whole import. It now reads `GET /imports/{id}/records` once every named import
44
+ is complete. `MCP_ROW_ID` round-trips through the synthesized CSV, so this
45
+ needs no client-side state — which is what makes it work on the hosted MCP,
46
+ where there is no BulkTracker at all (`http-server.ts` never passes one).
47
+ A records read that fails downgrades to status-only; it never turns a readable
48
+ status into an error.
49
+ - **A still-settling record is in neither bucket.** `reconcileOneChunk` can call
50
+ an unresolved record `internal_error` because it only runs after the records
51
+ settled; a status poll has no such guarantee. The status-side reconciler
52
+ counts them as `result.still_settling` instead. Calling a pending row "failed"
53
+ is what sends an agent back into the loop.
54
+ - **`notification_id` survives a timeout.** It was collected from
55
+ `runOneChunk`'s return value, so a call that timed out after `update_mappings`
56
+ threw away the very notification that would have told the agent the import
57
+ finished. Threaded out at mint time, mirroring `onImportId`.
58
+ - **`import_and_qualify` passes the running shape through** rather than throwing
59
+ `IMPORT_ASYNC_UNEXPECTED`, which would otherwise fire on exactly the incident
60
+ being fixed. Reuses the literal its `wait_for_completion:false` branch already
61
+ returns; `handle_id` is now optional on both.
62
+ - **Multi-chunk honesty:** a timeout on chunk 1 of 3 leaves chunks 2-3 unsent.
63
+ Those rows are reported as `rows_pending_upload`, and the description says
64
+ they DO need a fresh call for that subset — the one case where re-importing is
65
+ right.
66
+ - Shared record-reading primitives (`normalizeDomain`, `readCell`,
67
+ `PUBLIC_MAILBOX_DOMAINS`, `reconcileRecords`) moved to
68
+ `composite/_import-records.ts`. `import-leads.ts` re-exports `normalizeDomain`
69
+ for backward compatibility.
70
+
71
+ **Review round 2** — nine findings from the Codex and Claude reviewers, all
72
+ real, all fixed:
73
+
74
+ - **A dry run and an import parked mid-commit are byte-identical on the wire.**
75
+ Probed on us-staging: both answer `total_records: 0`,
76
+ `pre_processing.finished: true`, `processing` absent, `mappings` populated by
77
+ the backend's own AI hints, and `400 in_progress` on BOTH `/leads` and
78
+ `/records`. So the row cannot decide completion — the endpoints can.
79
+ `leadbay_import_status` now asks, and demotes a row that merely *looks*
80
+ finished to `status:"running"`, `phase:"committing"`. Without that, the window
81
+ between preprocess finishing and the detached finisher committing reported
82
+ `complete` with no leads and the agent stopped polling — the exact failure
83
+ this release exists to prevent.
84
+ - **`dry_run` is carried by the caller**, because nothing else can carry it.
85
+ The timed-out running result sets it; `leadbay_import_status` takes it as a
86
+ parameter. Absent it, a validation pass reads as `committing` rather than
87
+ risking a "N leads imported" render of an import that committed nothing.
88
+ - **`GET /imports/{id}/leads` is the source of truth for lead ids**, unioned
89
+ over the records-derived set, which now only annotates them with
90
+ rowId/domain/name. It is what `import_and_qualify` already trusts and it
91
+ includes leads the import created rather than matched; records-only would
92
+ drop those silently.
93
+ - **A snapshot short of the declared `total_records` reports the shortfall as
94
+ `still_settling`.** `processing.finished` can flip before every row is
95
+ exposed; counting only visible rows let a partial snapshot read as final.
96
+ - **A record carrying `lead.id` while still `MATCHING`/`IMPORTING` stays
97
+ pending.** The module's own `isRecordTerminal` says settled means `IMPORTED`
98
+ or `NO_MATCH`; the leads bucket now honours it instead of trusting a
99
+ lead id that the wizard may yet re-match.
100
+ - **`import_and_qualify` propagates `timed_out`, `rows_pending_upload`,
101
+ `dry_run` and the malformed rows.** Dropping them meant a >100-row batch
102
+ silently lost every unuploaded chunk and the agent had no cue to poll.
103
+ - **Malformed rows survive the degraded result.** They are rejected
104
+ client-side and never reach the backend, so `leadbay_import_status` could
105
+ never reconstruct them — dropping them let the caller read the batch as fully
106
+ accounted for.
107
+ - **No customer identity in this file.** It ships in the package's npm `files`
108
+ list; incident details belong in product#4007, not on npm.
109
+
110
+ **Review round 3** — seven more findings on the round-2 code, all real:
111
+
112
+ - **A transient `/leads` failure was being swallowed as "endpoint missing".**
113
+ Only a 404 is benign; a 500 or auth error means the canonical set is unknown,
114
+ and a records-only `result` would silently omit whatever `/leads` would have
115
+ added. Now only 404 falls back — and fixing it immediately surfaced a missing
116
+ `/leads` mock in the MCP E2E test that the old catch had been hiding.
117
+ - **The canonical merge keyed a map by `leadId`,** which collapsed the several
118
+ rows records-mode deliberately allows on one lead (separate contacts on one
119
+ company) and lost each row's `rowId`. Every reconciled row is kept; only ids
120
+ `/leads` knows and no record exposed are appended.
121
+ - **A canonical id could resurrect a non-terminal record** through the union,
122
+ undoing the terminal gate added in round 2. Ids belonging to a record that is
123
+ still MATCHING / IMPORTING are held back.
124
+ - **The settling deficit counted raw fetched rows,** so a re-paged duplicate
125
+ masked a genuine shortfall. Measured on distinct rows now.
126
+ - **The detached finisher inherited the caller's budget.** A caller who passed
127
+ a short `total_budget_ms` is exactly the caller most likely to time out;
128
+ giving the finisher that same window let it fail the one job it exists to do
129
+ and leave the import parked. It now carries its own 10-minute budget, does
130
+ only what it must — poll preprocess, commit the mappings — and is skipped
131
+ entirely for a dry run, which is *supposed* to stop after preprocess.
132
+ - **Two tool descriptions still said `leadbay_import_status` returns
133
+ status/progress only**, contradicting the recovery path this release adds.
134
+ - **`readCell` never looked at a cell's `field` name.** A records-mode import
135
+ that maps the header `Web` to LEAD_WEBSITE returns
136
+ `{column_name: "Web", field: "LEAD_WEBSITE"}`, and coalescing to the first
137
+ present name let `column_name` shadow `field` — so an unmatched row lost its
138
+ domain and rendered as "needs attention" instead of "pending crawl", without
139
+ the domain needed to retry. Pre-existing, but the stateless recovery path is
140
+ what made it bite.
141
+ - **`importIds` is deduped.** The same handle twice doubled the declared row
142
+ count against a record set that dedupes, pinning `still_settling` above zero
143
+ on an import that had entirely finished.
144
+ - **Records mode returns `row_ids`.** `MCP_ROW_ID` is a UUID minted inside the
145
+ tool; the caller has never seen it, yet `leadbay_import_status` reports
146
+ recovered leads keyed by it. A row identified only by `CRM_ID` — no website
147
+ to correlate on — was untraceable back to the leadId it produced.
148
+ - **A rejected mapping commit no longer polls for ever.** The detached
149
+ finisher sends `update_mappings` on the caller's behalf; if the backend
150
+ refuses it (an invalid mapping answers `400 missing LEAD_NAME field`), the
151
+ row it leaves is byte-identical to one still committing — no error field
152
+ anywhere. Before the timeout became a success result this surfaced as a plain
153
+ error, so staying silent would be a regression introduced by that very
154
+ change. The finisher records the rejection in a memory-only, best-effort
155
+ registry and `leadbay_import_status` reports `failed` with the backend's own
156
+ message; a restart just falls back to the old "still committing" reading.
157
+ - **A completed dry run says so.** Polling with `dry_run:true` returned
158
+ `complete` with no `result` and no discriminator, which the rendering
159
+ contract turns into "✓ Import complete" — a lie about an import that
160
+ committed nothing. The response now echoes `dry_run`.
161
+ - **A foreign `MCP_ROW_ID` column is not trusted as an identity.** A web-UI
162
+ import's own file may carry that header holding blanks or one repeated value;
163
+ treating those as our synthetic ids collapsed unrelated records onto one
164
+ dedupe key. Only a `randomUUID()`-shaped value counts.
165
+ - **A canonical id is only published when the snapshot is complete.**
166
+ `pendingLeadIds` can only speak for rows that were actually fetched, so while
167
+ rows are missing an unvouched id might belong to one of them and still be
168
+ MATCHING — downstream qualification would then act on a lead the wizard may
169
+ yet re-match. `row_ids` propagates through `import_and_qualify` too.
170
+ - **Record dedupe falls back to the backend record id.** `importIds` need not
171
+ name an MCP-created import; a web-UI one carries no `MCP_ROW_ID`, so keying
172
+ only on that let a re-paged row count twice while another went missing — and
173
+ a raw count matching `total_records` then read as a complete snapshot.
174
+
175
+ **Not shipped, deliberately:** the issue's criterion 6 asks to flip
176
+ `wait_for_completion` to default `false`. `http-server.ts:336` never passes a
177
+ `bulkTracker`, so on hosted that path throws `BULK_TRACKER_UNAVAILABLE` today
178
+ (product#4005) — the flip would break 100% of hosted imports. `BulkRecord` also
179
+ carries no org field, so a shared store on the multi-tenant hosted process would
180
+ make handles cross-tenant resolvable; that needs its own design. Parked behind
181
+ #4005. The degrade-to-running fix delivers the same benefit without it.
182
+
183
+ **Backend, not this repo:** the bimodal ~85s phase itself, and
184
+ `POST /leads/resolve`'s 81-95s slow mode (17 of 40 calls in the same session,
185
+ ~24 minutes of wall clock). Issue criteria 1 and 2.
186
+
3
187
  ## 0.30.0 — 2026-08-19
4
188
 
5
189
  Encode the **single-country rule** across every location-accepting surface
package/README.md CHANGED
@@ -634,7 +634,7 @@ Use `dry_run: true` to validate domain formatting and wizard reachability withou
634
634
  | `LEADBAY_MOCK` | no | unset | `"1"` serves all reads from on-disk fixtures (dev only) |
635
635
  | `LEADBAY_MOCK_DIR` | no | `./.context/leadbay-live-shapes/` | Fixture dir for mock mode |
636
636
  | `LEADBAY_LOG_LEVEL` | no | `error` | `debug` \| `info` \| `error`, logs to stderr |
637
- | `LEADBAY_TIMEOUT_MS` | no | (client default) | Per-request timeout override |
637
+ | `LEADBAY_TIMEOUT_MS` | no | `600000` | Backstop deadline for a single outbound Leadbay request, for the case where nothing cancels it. Not a latency budget: long work (enrichment, bulk qualify, import) is launched and polled, and a cancelled tool call already closes its own requests. On expiry the socket is closed and the tool returns a `TIMEOUT` error. Set `0` to disable the backstop. |
638
638
 
639
639
  > ⚠️ **Set `LEADBAY_REGION` explicitly.** If you don't, the server probes BOTH `api-us.leadbay.app` and `api-fr.leadbay.app` in parallel with your bearer token attached, sending the token to a backend that doesn't own your account. The `install` and `login` subcommands enforce `--region` for exactly this reason; the runtime auto-probe is a backwards-compat fallback, not a recommended setting.
640
640
 
@@ -657,6 +657,7 @@ Use `dry_run: true` to validate domain formatting and wizard reachability withou
657
657
  | `mcp tool called` | Every tool invocation | `tool`, `ok`, `duration_ms`, `format`, `bytes`, `error_code` (if failed) |
658
658
  | `mcp quota hit` | When the API returns `QUOTA_EXCEEDED` (HTTP 429/402) | `tool`, `retry_after_s`, `endpoint` |
659
659
  | `mcp topup link created` | When `leadbay_create_topup_link` returns a checkout URL | `tool` (the URL itself is **never** captured) |
660
+ | `mcp tool timeout` | When an outbound Leadbay request exceeds `LEADBAY_TIMEOUT_MS` | `tool`, `timeout_ms`, `endpoint`, `region` |
660
661
 
661
662
  After your first authenticated call, your PostHog `distinctId` is set to your Leadbay account email so MCP events consolidate with web-app events for the same person. Events also carry `$groups.organization` so org-level rollups work.
662
663