---
summary: "Live-fire fixes against Cassavabase: canvas dataframes survive null columns; deduplicates 11×-repeated synonyms; find_variables actually re-ranks against the spilled union; truthful truncated-dataset metadata; trims empty nested containers."
breaking: false
---

# 0.4.14 — 2026-05-04

Five bugs surfaced when probing the live Cassavabase surface. Each one was a quiet failure mode — wrong row counts, missing reranks, phantom duplicates — that didn't trip tests but shaped what an agent saw at the call site. Fixes verified against the upstream JSON. Internal `applyRowTransforms` refactor consolidates the spillover map-then-filter pattern. Framework bumped to 0.8.14.

## Fixed

- **Canvas auto-register no longer rolls back on null-bearing columns.** The framework's `schemaSniffer` infers `nullable: false` for any column that happened to be non-null in the first sniff sample, and DuckDB's appender rolls back the entire batch the first time a later row carries `null` for that column — surfaced live as `rowCount: 0` on a 348-row spillover. `CanvasBridge.registerDataset` now walks every row, classifies types, and emits an explicit all-nullable `ColumnSchema[]` so the dataframe accepts the full row set. The framework's sniffer bug is unaddressed upstream as of `@cyanheads/mcp-ts-core` 0.8.14; the workaround stays in place until the sniffer either widens to all-nullable by default or exposes an opt-in.
- **`brapi_find_germplasm` / `brapi_get_germplasm` deduplicate `synonyms` arrays by structural identity.** Cassavabase emits each registered synonym repeated 11× per germplasm record — observed 66 entries collapse to 6 unique. Identity is taken via a stable JSON serialization (recursive key sort) because the upstream emits two different key orderings (`{synonym, type}` and `{type, synonym}`) on the same record, so insertion-order-dependent `JSON.stringify` was missing dupes. Persisted dataset, in-context view, and canvas dataframe all see the cleaned shape consistently.
- **`brapi_find_variables` free-text rerank now walks the full upstream union, not the first page.** The ontology resolver matches against the spilled `fullRows` (e.g. all 750 variables on Cassavabase), but the rerank loop only iterated `firstPage.rows` (15 rows under the default `loadLimit`) — so matched dbIds that sat past the in-context window produced an empty `matched` set and a no-op rerank. "Dry matter content" now lands in `results[]` instead of orphaned in `ontologyCandidates[]`. Result is sliced back to `loadLimit` after the merge so the response stays bounded.
- **Truncated dataset handles no longer report a misleading `maxRows: 50000` when the page cap fired first.** `MAX_SPILLOVER_PAGES = 50` × a small `loadLimit` (e.g. 15) caps the spillover at 750 rows, well below the 50,000-row cap — but `spillToDataset` was unconditionally tagging `maxRows = MAX_SPILLOVER_ROWS` whenever truncation occurred. `maxRows` now appears only when the row cap actually fired; page-cap events log a structured warning for operators and surface to the agent as `truncated: true` without the false 50k bound, signalling that raising `loadLimit` will push past it.

## Changed

- **`brapi_find_variables` strips top-level keys whose value is an empty container** (`method: {}`, etc.) from the in-context rows. Cassavabase attaches these to every variable record and the bloat is pure noise. Conservative: a nested object is dropped only when every leaf is null/undefined/empty-string; objects with any non-empty field are kept untouched, including their null siblings. Applied to in-context rows only — the spilled dataset and canvas dataframe keep the raw upstream shape for research traceability.
- **`brapi_find_variables.text` description rewritten** to match what the code now does — "Ranks the full upstream union (the spilled dataset when one is produced, otherwise the first page) via the ontology resolver, then fills the in-context window up to loadLimit with matches first and unmatched rows for context." The old wording said "Re-ranks (does not subset) the returned rows," which understated where the rerank actually pulls from.
- **Internal: `applyRowTransforms` consolidates the spillover map-then-filter pattern.** Three sites in `find-helpers.ts` (`maybeSpill` early-exit, `maybeSpill` error fallback, `spillToDataset`) had been duplicating the same two-line "apply mapper before filter" sequence. Centralizing it removes the drift risk that any one call site silently skips the new `rowMapper` hook.
- **`@cyanheads/mcp-ts-core` ^0.8.13 → ^0.8.14.** Polish release upstream — adds `tool-defs-analysis` skill (read-only LLM-language audit across a server's definitions) and re-exports `disabledTool` from the package root. AGENTS.md / CLAUDE.md sync picks up the "Declare contracts inline on each tool" guidance.
