fix(mcp): remove shadowed tool classes and make product/contract tools read the DB - #677
Open
ferdinand-van-butzelaar wants to merge 1 commit into
Conversation
…s read the DB `src/tools/data_products.py` and `src/tools/data_contracts.py` each defined the same tool class twice. Python keeps the last definition, so the copies that later maintenance commits kept editing were the dead ones, and the copies that actually ran were the originals from 6de81fd. Three consequences, all live on `development`: 1. Five registered tools lost their `required_scope`. 35c0ea6 added scopes to the shadowed copies only, so `get_data_product`, `delete_data_product`, `search_data_contracts`, `get_data_contract` and `delete_data_contract` fell back to `BaseTool.required_scope = "*"`. They are hidden from `tools/list` and rejected by `tools/call` for every token that does not carry the admin wildcard. 2. The whole data-contract tool surface was non-functional. The running copies called `DataContractsManager.list_contracts()` / `get_contract()` / `delete_contract()`, which are the legacy in-memory store. Nothing populates `_contracts` at runtime -- those four tool call sites are its only callers in the backend -- so `search_data_contracts` always returned zero results and `get`/`delete` always reported "not found". 3. Descriptions and output ports were always empty. `DataProductDb.description` is a relationship to `DescriptionDb` and `output_ports` a relationship to `OutputPortDb`, not JSON columns, so `.get('purpose')` and the `isinstance(port, dict)` branch never matched. `DataContractDb` has no `description` attribute at all (`description_purpose` and friends), so the surviving contract tools raised AttributeError against real rows. Keeps the maintained copies (junction-table domains from databrickslabs#520, declared scopes), drops the duplicates, and adds `_description_purpose` / `_output_port_names` helpers that accept the ORM, dict and JSON shapes. `delete_data_contract` now goes through `delete_contract_from_db`. Also fixes databrickslabs#660: `list_data_products` passed `domain`/`status` kwargs that `DataProductsManager.list_products` does not accept. The tool was never registered, so the TypeError was unreachable -- same for `list_data_contracts`. Both now query the DB the way their `search_*` siblings do, and both are registered. Tests: an AST guard that fails on any duplicate class in any tool module, a guard that every registered tool declares its own `required_scope`, and DB-backed behaviour tests for the product and contract tools. 18 of the 35 fail on `development`. Note: `ruff` flags the shadowing as F811, but `ruff check` has been unrunnable since v0.5.4 -- `scripts/bump_version.py` rewrites every `version = "..."` in `pyproject.toml`, including `[tool.ruff] target-version`. Filed separately. Closes databrickslabs#660 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
mvkonchits-db
approved these changes
Aug 7, 2026
mvkonchits-db
left a comment
Contributor
There was a problem hiding this comment.
Thanks for a genuinely thorough writeup — I verified every diagnostic claim against development source rather than taking the description at face value, and it all checks out. Approving.
Diagnosis confirmed:
- Duplicate class defs are real (
GetDataProductTool@17 & @520,DeleteDataProductTool@101 & @669; contracts @16/@479, @139/@564, @209/@689). Python keeps the last, so maintenance edits landed on the dead top copies. - The running (bottom) copies carry no
required_scope, so they inheritBaseTool's"*"default._has_scopeonly passes the wildcard when the token literally carries"*", so those five tools were invisible intools/listand rejected bytools/callfor every least-privilege token. Confirmed. - Contract tools called the legacy in-memory
_contractsstore (list_contracts()/get_contract()/delete_contract()), which has no runtime populator — so they returned zero against real DB rows. Confirmed. DataContractDbhas nodescriptionattribute (onlydescription_purpose/usage/limitations), andDataProductDb.description/output_portsare relationships, not JSON — so the old.get('purpose')/isinstance(port, dict)paths never matched. Confirmed.
Fix verified:
- No duplicate classes remain; the AST guard would catch any regression — a good structural guard, not busywork.
- All eight tools now declare their own
required_scope; the newly-registeredlist_*tools carry the read scope. - The rewritten
list_*tools query the DB exactly like the workingsearch_*siblings;get_domains_for_entities(db, *, entity_type, entity_ids)matches the repo signature and theentity_typevalues (data_contract/data_product) match the existing tools. delete_data_contractcorrectly routes throughdelete_contract_from_dbwithValueError -> not-foundhandling.- The DB-backed tests insert real rows with matching column names, so they genuinely exercise the fix.
Two things worth a conscious maintainer sign-off (non-blocking):
- Access-surface widening, by design. Today these five tools are effectively admin-only (inherited wildcard). After this merge a
contracts:read/data-products:readtoken can call them. That is the intended fix, but it is a real broadening of the MCP surface — flagging so it's a deliberate decision, not a side effect nobody noticed. .limit(500).all()then Python-side domain filter then break atlimit(default 50): matches beyond the first 500 rows would be missed. Same pattern as the existingsearch_*tools, so it's consistent — minor scalability caveat only.
One note for whoever merges: CI hasn't run here (fork PR from a first-time contributor needs workflow approval). My review is a static verification against source plus a read of the test suite; worth letting the 35 tests actually execute before merge.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
src/backend/src/tools/data_products.pyandsrc/backend/src/tools/data_contracts.pyeach define the same tool class twice.6de81fd1("Add LLM CRUD tools for app objects") added a CRUD block at the top of each module and another at the bottom:Python keeps the last definition. Every maintenance commit since has landed on the copy at the top of the file — the dead one — while the copy at the bottom is what actually executes.
Why it matters
1. Five registered tools silently lost their scope
35c0ea6f("Add MCP endpoint") introducedrequired_scopeand added it to the shadowed copies only. Ondevelopmenttoday:MCPProtocolHandler._has_scopeonly passes"*"when the token literally carries the wildcard, andtools/listfilters by the same check (mcp_routes.py:233). So those five tools are invisible intools/listand rejected bytools/callfor every least-privilege MCP token — a token minted withcontracts:readcannot usesearch_data_contractsat all.2. The data-contract tool surface is non-functional
The running copies call
DataContractsManager.list_contracts()/get_contract()/delete_contract()— the legacy in-memory_contractsdict, not the database. Nothing populates it at runtime:create_contracthas no callers,load_from_yamlis never invoked for this manager, and those four tool call sites are the store's only callers in the entire backend.Verified against a session with a real
DataContractDbrow present:3. Descriptions and output ports were always empty
DataProductDb.descriptionis a relationship toDescriptionDbandoutput_portsa relationship toOutputPortDb— not JSON columns.json.loads(...) if isinstance(str)falls through to the ORM object,.get('purpose')is never reached and theisinstance(port, dict)branch never matches. Sosearch_data_productsnever matched on description text andoutput_tableswas always[].DataContractDbhas nodescriptionattribute at all (the ODCS description is split acrossdescription_purpose/description_usage/description_limitations), so the shadowed contract tools raisedAttributeError: 'DataContractDb' object has no attribute 'description'against real rows. Both copies were broken — just differently.What this PR does
#520junction-table domain handling and the declaredrequired_scope._description_purpose()and_output_port_names()to both modules, accepting the ORM, dict and JSON-string shapes so the tools work regardless of which layer hands them the object.delete_data_contractthroughdelete_contract_from_dbinstead of the in-memory store.list_data_products/list_data_contracts. Both now query the DB the way theirsearch_*siblings do, with any-of domain matching overentity_domain_associationsand status/limit filters.Net: 170 insertions, 447 deletions.
Fixes #660
list_data_productspasseddomain=/status=toDataProductsManager.list_products, which accepts neither. Worth noting for the issue thread: the tool was never registered (create_default_registrydoesn't import it), so theTypeErrorwas unreachable — same forlist_data_contracts. Simply dropping the kwargs would not have been enough either:list_productsfail-closes to an empty list without caller scope, andToolContextcarries no caller identity, so the tool would have gone from raising to silently returning nothing. Querying the DB directly matches whatsearch_data_productsalready does and sidesteps that.Tests
New
src/backend/src/tests/unit/test_tool_module_integrity.py(35 tests):src/tools/defines a class more than once. This is the guard that would have caught the original mistake.BaseTool.required_scopeinstead of declaring its own, plus explicit assertions on the eight product/contract tool scopes.DataContractDb/DataProductDbrows and assert the tools find them, match on purpose text, filter by status, and return descriptions and output ports.Results:
backend/src/tests/unit+backend/tests)Note on ruff
ruffalready flags this asF811 redefined-while-unused— 5 hits in these two files ondevelopment, 0 after this change. It went unnoticed becauseruff checkhas been unrunnable since v0.5.4 (Feb 2026):scripts/bump_version.pymatches(version\s*=\s*")[^"]+(")unanchored, so every release rewrites[tool.ruff] target-versionto the app version. It waspy310when the tooling landed in53fce85band has been clobbered by every bump since. That also disables theruffandruff-formatpre-commit hooks. Filed separately rather than bundled here, because restoring it surfaces ~750 pre-existing violations that are yours to sequence.