Repository navigation
feat(mcp): collapse list_* into search wrappers + dedupe tags + add include_facets / fields - #954
larsgeorge-db wants to merge 3 commits into
Conversation
…add include_facets/fields An agent-driven test of the MCP tool set surfaced five related inefficiencies. This commit addresses all of them in one coordinated change. 1. **list_data_products was broken.** The tool called `data_products_manager.list_products(skip, limit, domain=..., status=...)`, but the manager method doesn't accept `domain` or `status` kwargs — every call raised TypeError and returned a generic error. Even if the signatures matched, the method defaults to `is_admin=False` with no caller scope, which returns an empty list by fail-closed design. `list_data_contracts` didn't have the kwarg bug but hardcoded `is_admin=True` and filtered domain post-fetch inefficiently. Fix: both ListData*Tool.execute() now delegate to the corresponding SearchData*Tool with query='*'. One implementation, one shape. The tool descriptions tell the agent to prefer search_* for anything non-trivial (same hint pattern already used on get_data_products). 2. **Duplicate tags in indexed items.** `_build_search_index_item` on both managers accumulates tag_strings/tag_names from multiple sources (unified tags, assigned domains, ontosOriginalDomain, concept labels, team members) and `SearchIndexItem(tags=...)` carried the duplicates. The result's `tags[:5]` slice then wasted slots on repeat strings — the agent observed `["Supply Chain", "Inventory", "Inventory"]` on real products. Fix: order-preserving dedupe at the end of each builder. 3. **feature_id leaked into every search result.** `_to_result` in search_scoring (and `_row_to_result` in search_repository for the Postgres backend) emitted `feature_id` on every row. `type` already differentiates data-product from data-contract for MCP consumers, so `feature_id` was near-redundant and inflated every list/search call. Fix: drop `feature_id` from both result builders. `type` and `link` stay — `type` is useful for mixed-result handling and `link` is useful when an agent renders a UI reference. 4. **Facets always computed.** `search_scoring.search_index(include_facets=True)` already supports opting out of facet computation, but the MCP tool signatures didn't expose it. On a 'then fetch details' flow, the facet block is pure overhead. Fix: `search_data_products` and `search_data_contracts` now accept `include_facets: bool = True` and thread it through to query_index. 5. **No fields/projection parameter.** Every call paid for description + tags + owner + version even when the agent only needed id + name. Fix: add `fields: Optional[List[str]]` to the search_* signatures and thread it through `query_index` -> backend `search` -> `search_scoring` / `search_repository`. The projection is applied per-row with `id` always retained so the caller can correlate / fetch details. Tests (new file `test_mcp_tool_efficiency.py`, 11 cases): - list_data_products / list_data_contracts delegate to search_* with query='*' (regression guard for the TypeError + the inconsistent list shape). - _to_result and the Postgres `_row_to_result` omit feature_id. - fields=['title','status'] narrows rows; empty/None leaves them unchanged. - include_facets=false returns an empty facets block, records unchanged. - Product + contract index builders dedupe tag_strings/tag_names. - search_* passes include_facets and fields through to query_index. `test_mcp_contracts_db_backed.py::test_list_includes_db_written_contract` updated: it now builds a SearchManager over the test session so the "DB-backed list" invariant (#918) is preserved through the search path. Session: claude -r 171078d5-61c9-42b9-aeb6-5d378c386f35 Co-authored-by: Isaac <no-reply@databricks.com>
mvkonchits-db
left a comment
There was a problem hiding this comment.
Requesting changes on one point (1). The rest are notes. The direction is good: list_data_products really was broken on development (it passes domain and status to list_products, which does not accept them, so every call raised a TypeError), and collapsing the list tools into the search tools removes a whole class of drift. I checked the points below in the code. I did not run the tests.
1. list_data_contracts can now show stale data. #923 made this tool read from the database on purpose, because the in-memory path was empty. With this PR it reads the search index instead, and the index is not updated on every status change:
DataContractsManager.transition_statussets the status and commits, with no_update_search_index. The three REST endpoints that call it (data_contracts_routes.pyaround lines 225, 276 and 340) do not reindex either.workflow_executorsetsactiveanddeployeddirectly on the row, also without reindexing.
So a contract that has just become active can show up as draft in list_data_contracts, and status=active misses it until the next restart or index rebuild. search_data_contracts already has this gap today, but this PR extends it to the tool that #923 fixed. The updated test test_list_includes_db_written_contract builds the index after the write, and its docstring says so, so it cannot catch this. Please either call _update_search_index after status transitions (and in the workflow path), or keep the list tool on the database. A test that creates a contract, builds the index, then changes the status and calls the list tool would cover it.
2. The list_data_contracts response keys change. name becomes title and owner_team_name becomes owner, because it now returns search rows. The description mentions the new shape but not the renames. Nothing breaks for products (that tool never worked), but contract consumers should be told.
3. fields fails silently on a wrong name. The projection keeps only keys that exist in the row, and the row key is title, not name. fields=['name', 'status'] returns id and status only, with no error. Either validate the names and return a clear message, or accept name as an alias, since the old list tools used it.
Checked and fine:
- Dropping
feature_idonly affects the MCP result dicts. The frontend search uses/api/search, which returnsSearchIndexItemobjects and is unchanged. - Domain and status filters stay exact and case insensitive, and the limit of 100 is enforced in both backends.
- The tag de-duplication keeps order and also reduces the duplicate tags I flagged on #921. It only dedupes exact strings, so
Financeandfinancestay separate. fieldsis a membership check on a fixed set of keys, so there is no injection risk.- The PR merges cleanly with #953 and #921.
I will re-review as soon as point 1 is addressed.
…fields) into combined test branch
The scorer puts `type` + `link` on every record so the UI global search (which mixes entity types) can route and distinguish them. For an MCP agent calling a per-type tool (search_data_products / list_data_products and the contracts twins) the tool name already carries the type, and `link` is a UI route path with no agent-side use. Drop both from every record returned by those four tools. The strip is unconditional — an explicit `fields=["type", "link"]` won't resurrect them. The shared `_to_result` and the UI global-search path are untouched, so mixed-type results keep both fields. Four tests pin the behaviour; existing scorer tests continue to assert `type`/`link` are present at the shared layer. Co-authored-by: Isaac <no-reply@databricks.com>
The contract index builder composed `description` as `"{version} • {status}
• {description_usage}"`. Both version and status are already stored as
structured `extra_data` fields and surface as separate top-level keys on
each search result row via `_RESULT_FIELDS` — the composite was pure
duplication that cost tokens on every list/search call and made the
description harder for MCP agents to parse. Products never did this.
Store just the raw `description_usage`. UI result cards get a cleaner
description line; MCP agents get a parseable string plus the structured
version/status they already had.
Co-authored-by: Isaac <no-reply@databricks.com>
Summary
An agent-driven test of the live MCP tool set surfaced inefficiencies — one real bug and shape issues that make every call heavier than it needs to be. This PR addresses them in one coordinated change.
1.
list_data_productswas broken. It calleddata_products_manager.list_products(skip, limit, domain=..., status=...)but the manager method doesn't acceptdomainorstatuskwargs — every call raisedTypeErrorand returned a generic error. Even if the signatures matched, the method defaults tois_admin=Falsewith no caller scope → fail-closed empty list.list_data_contractsdidn't have the kwarg bug but hardcodedis_admin=Trueand filtered domain post-fetch inefficiently, and returned a different shape than itssearch_*sibling.Fix: both
ListData*Tool.execute()now delegate to the correspondingSearchData*Toolwithquery='*'. One implementation, one response shape, no bug surface. Tool descriptions tell the agent to prefersearch_*for anything non-trivial (same pattern already used onget_data_products).2. Duplicate tags in indexed items. Both managers'
_build_search_index_itemaccumulate tags from multiple sources (unified tags, assigned domains,ontosOriginalDomain, concept labels, team members) without dedupe. The result'stags[:5]slice then wasted slots on repeats — the agent observed e.g.["Supply Chain", "Inventory", "Inventory"].Fix: order-preserving dedupe at the end of each builder.
3.
feature_idleaked into every search result._to_resultand the Postgres_row_to_resultemittedfeature_idon every row.typealready differentiatesdata-productfromdata-contractfor MCP consumers, so this was near-redundant.Fix: drop
feature_idfrom both result builders.4. Facets always computed.
search_scoring.search_index(include_facets=True)already supports opting out, but the MCP tool signatures didn't expose it.Fix:
search_data_productsandsearch_data_contractsnow acceptinclude_facets: bool = Trueand thread it throughquery_index→ backendsearch→search_scoring/search_repository.5. No fields / projection parameter. Every call paid for description + tags + owner + version even when the agent only needed id + name.
Fix: add
fields: Optional[List[str]]to the search_* signatures, applied per-row (idalways retained). Threaded end-to-end through both the in-memory and Postgres backends.6.
typeandlinkleaked into every per-type MCP record. The shared scorer (_to_result+ Postgres_row_to_result) emitstypeandlinkso the UI global search — which mixes entity types — can route and distinguish them. For an MCP agent calling a per-type tool (search_data_products/list_data_productsand the contracts twins), the tool name already carries the type, andlinkis a UI route path with no agent-side use.Fix: strip
typeandlinkfrom every record in the four per-type MCP tools, afterquery_indexreturns. Unconditional — an explicitfields=["type","link"]won't resurrect them. The shared scorer and the UI global-search path are untouched, so mixed-type results keep both fields.7. Contract search description was a composite UI string. The contract index builder composed
descriptionas"{version} • {status} • {description_usage}". Both version and status were already stored as structuredextra_datafields and surface as separate top-level keys on each search result row via_RESULT_FIELDS— the composite was pure duplication that cost tokens on every list/search call and made the description harder for MCP agents to parse. Products never did this.Fix: store just the raw
description_usagein the index. UI result cards get a cleaner description line; MCP agents get a parseable string plus the structured version/status they already had.Tests
src/backend/src/tests/unit/test_mcp_tool_efficiency.py— 16 unit tests.TestListTools— list_data_products / list_data_contracts delegate to the search tool withquery='*', correct type_filter, correct filter kwargs.TestToResult—_to_resultomitsfeature_id;fields=[...]narrows rows (always keepsid);include_facets=Falsereturns empty facets with records unchanged. Also pins thattype+linkstay at the shared scorer layer (needed by UI global search).TestTagDedupe— product and contract index builders dedupe tags preserving insertion order.TestSearchParamPassthrough—search_*passesinclude_facetsandfieldsthrough toquery_index; defaults are back-compat.TestPerRecordTypeAndLinkStrip—search_data_products,search_data_contracts, and theirlist_*thin wrappers all droptypeandlinkfrom every result record.TestContractDescriptionIsRaw— contract index stores rawdescription_usagewith noversion • status •prefix; version/status remain available viaextra_data.Also updated:
test_mcp_contracts_db_backed.py::test_list_includes_db_written_contractnow builds aSearchManagerover the test session so the "DB-backed list" invariant (#918) still holds through the search path.Test plan
list_data_productsvia the MCP client — expect no 500, response shape matchessearch_data_products(products / total_found / has_more / offset / limit / facets).list_data_contracts— same shape assearch_data_contracts.search_data_productswithinclude_facets=false— facets block is empty, records present.search_data_productswithfields=['title','status']— rows contain only id, title, status.feature_id,type, orlink.descriptionno longer carries theversion • status •prefix; version/status still surface as separate structured keys on each row.tagslist on a product that previously duplicated.typeandlinkon each record (unchanged behaviour at the shared scorer layer).This pull request and its description were written by Isaac.