Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
176 changes: 176 additions & 0 deletions src/backend/src/tests/unit/test_mcp_tool_projections.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
"""Unit tests for the MCP get-tool projections.

The agent cannot answer ownership questions for imported ODPS/ODCS entities
unless `_serialize_product` / `_serialize_contract` surface the imported team
block (with roles). It also cannot reconcile an upstream reference unless
`original_id` (preserved as the `ontosOriginalId` custom property when
`adopt_ids=false`) comes down the wire. These tests pin that contract.
"""
from datetime import datetime
from types import SimpleNamespace

from src.tools.data_products import _serialize_product
from src.tools.data_contracts import _serialize_contract


def _member(username, name=None, role=None):
return SimpleNamespace(
username=username, name=name, role=role,
dateIn="2026-01-01", dateOut=None, replacedByUsername=None,
)


def _custom_prop(prop, value):
return SimpleNamespace(property=prop, value=value)


def _product(**overrides):
base = dict(
id="p1", name="P1", domain="sales", description=None,
status="active", version="1.0.0",
owner_team_id=None, owner_team_name=None,
outputPorts=[], tenant=None, team=None, customProperties=None,
created_at=None, updated_at=None, published_at=None, productCreatedTs=None,
)
base.update(overrides)
return SimpleNamespace(**base)


def _contract(**overrides):
base = dict(
id="c1", name="C1", domain="sales", description=None,
status="active", version="1.0.0",
owner_team_name=None, dataProduct=None,
team=[], customProperties=None,
created=None, updated=None, published_at=None,
)
base.update(overrides)
return SimpleNamespace(**base)


# =====================================================================
# Products
# =====================================================================

class TestSerializeProduct:
def test_includes_team_members_with_role(self):
"""ODPS team block + members[*].role must reach the agent — the whole
point of this change. dateIn/dateOut/replacedByUsername are explicitly
omitted (not useful for ownership answers)."""
team = SimpleNamespace(
name="Platform",
description="Platform ops",
members=[_member("eo@x", "Eve Operations", "owner"),
_member("js@x", "Jane Steward", "data steward")],
)
out = _serialize_product(_product(team=team))

assert out["team"]["name"] == "Platform"
assert out["team"]["description"] == "Platform ops"
assert [m["role"] for m in out["team"]["members"]] == ["owner", "data steward"]
assert out["team"]["members"][0]["name"] == "Eve Operations"
# Verbose fields intentionally dropped from the projection:
for m in out["team"]["members"]:
assert "dateIn" not in m
assert "dateOut" not in m
assert "replacedByUsername" not in m

def test_handles_no_team(self):
"""A product without imported ODPS team metadata serializes `team: None`
— no KeyError, no empty-object surprise."""
out = _serialize_product(_product(team=None))
assert out["team"] is None

def test_handles_malformed_member_with_only_username(self):
"""A member with just `username` (no `name`, no `role`) must still be
projected — e.g. roles are optional on the ODPS TeamMember model."""
team = SimpleNamespace(name="T", description=None,
members=[_member("only-un@x")])
out = _serialize_product(_product(team=team))
m = out["team"]["members"][0]
assert m["username"] == "only-un@x"
assert m["name"] is None
assert m["role"] is None

def test_timestamp_serialization(self):
"""datetime timestamps come down as ISO strings; `None` passes through."""
now = datetime(2026, 10, 11, 12, 0, 0)
out = _serialize_product(_product(
created_at=now, updated_at=now, published_at=None, productCreatedTs=now,
))
assert out["created_at"] == "2026-10-11T12:00:00"
assert out["updated_at"] == "2026-10-11T12:00:00"
assert out["published_at"] is None
assert out["product_created_ts"] == "2026-10-11T12:00:00"

def test_surfaces_original_id_from_custom_properties(self):
"""When `adopt_ids=false`, the YAML id is preserved as the
`ontosOriginalId` custom property; the projection must surface it so an
agent can reconcile the Ontos UUID with an upstream reference."""
props = [
_custom_prop("ontosOriginalDomain", "marketing-ops"),
_custom_prop("ontosOriginalId", "upstream-abc-123"),
_custom_prop("other", "ignore"),
]
out = _serialize_product(_product(customProperties=props))
assert out["original_id"] == "upstream-abc-123"

def test_original_id_missing_is_none(self):
"""No `ontosOriginalId` entry (and no customProperties at all) returns
`original_id: None`."""
assert _serialize_product(_product(customProperties=None))["original_id"] is None
assert _serialize_product(_product(customProperties=[]))["original_id"] is None
other_only = [_custom_prop("foo", "bar")]
assert _serialize_product(_product(customProperties=other_only))["original_id"] is None


# =====================================================================
# Contracts
# =====================================================================

class TestSerializeContract:
def test_includes_team_members_with_role(self):
"""ODCS `team` is a flat List[TeamMember] (no Team wrapper), carrying
username/role. Projection must include all with roles."""
team = [
SimpleNamespace(username="eo@x", name="Eve Operations", role="owner",
dateIn=None, dateOut=None, replacedByUsername=None),
SimpleNamespace(username="js@x", name="Jane Steward", role="data steward",
dateIn=None, dateOut=None, replacedByUsername=None),
]
out = _serialize_contract(_contract(team=team))
assert len(out["team"]) == 2
assert out["team"][0] == {"username": "eo@x", "name": "Eve Operations", "role": "owner"}
assert out["team"][1]["role"] == "data steward"
for m in out["team"]:
assert set(m.keys()) == {"username", "name", "role"}

def test_handles_no_team(self):
"""Empty team list serializes as an empty list, not None, matching the
ODCS DataContractRead shape (team has `default_factory=list`)."""
out = _serialize_contract(_contract(team=[]))
assert out["team"] == []

def test_timestamp_passthrough(self):
"""DataContractRead stores timestamps as ISO strings already — the
projection must pass them through without re-serializing."""
out = _serialize_contract(_contract(
created="2026-10-01T10:00:00",
updated="2026-10-05T11:00:00",
published_at="2026-10-06T12:00:00",
))
assert out["created_at"] == "2026-10-01T10:00:00"
assert out["updated_at"] == "2026-10-05T11:00:00"
assert out["published_at"] == "2026-10-06T12:00:00"

def test_surfaces_original_id_from_dict_custom_properties(self):
"""Contracts expose customProperties as a Dict[str, Any] (not a list of
CustomProperty like products). The lookup must handle the dict shape."""
props = {"ontosOriginalId": "upstream-c-999", "other": "x"}
out = _serialize_contract(_contract(customProperties=props))
assert out["original_id"] == "upstream-c-999"

def test_original_id_missing_is_none(self):
assert _serialize_contract(_contract(customProperties=None))["original_id"] is None
assert _serialize_contract(_contract(customProperties={}))["original_id"] is None
assert _serialize_contract(_contract(customProperties={"foo": "bar"}))["original_id"] is None
60 changes: 54 additions & 6 deletions src/backend/src/tools/data_contracts.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,24 @@
logger = get_logger(__name__)


def _original_id_from_props(custom_properties) -> Optional[str]:
"""Return the preserved YAML id (``ontosOriginalId``) from a contract's
customProperties. Contracts expose customProperties as a dict (not a list
of CustomProperty like products), so this is a direct lookup."""
if not custom_properties:
return None
if isinstance(custom_properties, dict):
value = custom_properties.get('ontosOriginalId')
return value if value else None
# Defensive: fall back to the list-of-objects shape used elsewhere.
for prop in custom_properties:
name = getattr(prop, 'property', None) if not isinstance(prop, dict) else prop.get('property')
if name == 'ontosOriginalId':
value = getattr(prop, 'value', None) if not isinstance(prop, dict) else prop.get('value')
return value if value else None
return None


def _serialize_contract(contract) -> Dict[str, Any]:
"""Full-detail dict for a single data contract.

Expand All @@ -21,6 +39,20 @@ def _serialize_contract(contract) -> Dict[str, Any]:
"""
desc = getattr(contract, 'description', None)
desc_purpose = getattr(desc, 'purpose', None) if desc is not None else None

# ODCS team: a flat List[TeamMember] on DataContractRead (not wrapped in a
# Team object like products). Project username/name/role so an agent can
# answer ownership questions. dateIn/dateOut/replacedByUsername omitted.
team = []
for member in (getattr(contract, 'team', None) or []):
if member is None:
continue
team.append({
"username": getattr(member, 'username', None),
"name": getattr(member, 'name', None),
"role": getattr(member, 'role', None),
})

return {
"id": contract.id,
"name": contract.name,
Expand All @@ -29,7 +61,16 @@ def _serialize_contract(contract) -> Dict[str, Any]:
"status": contract.status,
"version": contract.version,
"owner_team_name": getattr(contract, 'owner_team_name', None),
"team": team,
"data_product": getattr(contract, 'dataProduct', None),
# Preserved original id when adopt_ids=false (or the YAML id was invalid /
# colliding) — enables upstream-reference reconciliation.
"original_id": _original_id_from_props(getattr(contract, 'customProperties', None)),
# Lifecycle timestamps — DataContractRead stores these as pre-serialized
# ISO strings (unlike products which hold datetime objects).
"created_at": getattr(contract, 'created', None),
"updated_at": getattr(contract, 'updated', None),
"published_at": getattr(contract, 'published_at', None),
"url": f"/data-contracts/{contract.id}",
}

Expand Down Expand Up @@ -345,7 +386,13 @@ class GetDataContractTool(BaseTool):

name = "get_data_contract"
category = "data_contracts"
description = "Get detailed information about a specific data contract by its ID."
description = (
"Get detailed information about a specific data contract by its ID. "
"Includes the ODCS team members with their roles so you can answer "
"ownership / data-steward questions, lifecycle timestamps (created / "
"updated / published), and the preserved original id from the source "
"YAML when adoption was off."
)
parameters = {
"contract_id": {
"type": "string",
Expand Down Expand Up @@ -391,11 +438,12 @@ class GetDataContractsBulkTool(BaseTool):
name = "get_data_contracts"
category = "data_contracts"
description = (
"Fetch full details for MULTIPLE data contracts by ID in a single call. "
"Prefer this over repeated get_data_contract when you already hold a set "
"of ids. Returns up to 20 by default; raise 'limit' up to 100. Ids beyond "
"the limit are not fetched and reported via 'truncated'/'requested' so you "
"can page."
"Fetch full details for MULTIPLE data contracts by ID in a single call "
"(including the ODCS team members with their roles, lifecycle timestamps, "
"and the preserved original id from the source YAML). Prefer this over "
"repeated get_data_contract when you already hold a set of ids. Returns "
"up to 20 by default; raise 'limit' up to 100. Ids beyond the limit are "
"not fetched and reported via 'truncated'/'requested' so you can page."
)
parameters = {
"contract_ids": {
Expand Down
68 changes: 63 additions & 5 deletions src/backend/src/tools/data_products.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,25 @@
# SearchManager.query_index() so the active backend (memory or postgres) is used.


def _iso_or_none(dt) -> Optional[str]:
"""ISO-serialize a datetime/date; None stays None."""
return dt.isoformat() if dt is not None else None


def _original_id_from_props(custom_properties) -> Optional[str]:
"""Return the preserved YAML id (``ontosOriginalId``) from a product's
customProperties, if present. Lets an agent reconcile the Ontos UUID with
an upstream reference without a second call."""
if not custom_properties:
return None
for prop in custom_properties:
name = getattr(prop, 'property', None) if not isinstance(prop, dict) else prop.get('property')
if name == 'ontosOriginalId':
value = getattr(prop, 'value', None) if not isinstance(prop, dict) else prop.get('value')
return value if value is not None else None
return None


def _serialize_product(product) -> Dict[str, Any]:
"""Full-detail dict for a single data product.

Expand Down Expand Up @@ -53,6 +72,27 @@ def _serialize_product(product) -> Dict[str, Any]:
"location": location,
})

# Imported ODPS team: name + members with their role, so an agent can answer
# "who is the owner / data steward" questions. dateIn/dateOut/replacedByUsername
# omitted — not useful for ownership answers and inflates the payload.
team = None
product_team = getattr(product, 'team', None)
if product_team is not None:
members = []
for member in (getattr(product_team, 'members', None) or []):
if member is None:
continue
members.append({
"username": getattr(member, 'username', None),
"name": getattr(member, 'name', None),
"role": getattr(member, 'role', None),
})
team = {
"name": getattr(product_team, 'name', None),
"description": getattr(product_team, 'description', None),
"members": members,
}

return {
"id": product.id,
"name": product.name,
Expand All @@ -62,8 +102,18 @@ def _serialize_product(product) -> Dict[str, Any]:
"version": product.version,
"owner_team_id": getattr(product, 'owner_team_id', None),
"owner_team_name": getattr(product, 'owner_team_name', None),
"team": team,
"output_ports": output_ports,
"tenant": getattr(product, 'tenant', None),
# Preserved original id when adopt_ids=false (or the YAML id was invalid /
# colliding) — enables upstream-reference reconciliation.
"original_id": _original_id_from_props(getattr(product, 'customProperties', None)),
# Lifecycle timestamps — agents frequently want "when was this registered /
# last updated / published" alongside the ownership info.
"created_at": _iso_or_none(getattr(product, 'created_at', None)),
"updated_at": _iso_or_none(getattr(product, 'updated_at', None)),
"published_at": _iso_or_none(getattr(product, 'published_at', None)),
"product_created_ts": _iso_or_none(getattr(product, 'productCreatedTs', None)),
"url": f"/data-products/{product.id}",
}

Expand Down Expand Up @@ -368,7 +418,13 @@ class GetDataProductTool(BaseTool):

name = "get_data_product"
category = "data_products"
description = "Get detailed information about a specific data product by its ID."
description = (
"Get detailed information about a specific data product by its ID. "
"Includes the imported ODPS team metadata (members with roles) so you "
"can answer ownership questions, lifecycle timestamps (created / "
"updated / published / product_created_ts), and the preserved original "
"id from the source YAML when adoption was off."
)
parameters = {
"product_id": {
"type": "string",
Expand Down Expand Up @@ -410,10 +466,12 @@ class GetDataProductsBulkTool(BaseTool):
category = "data_products"
description = (
"Fetch full details for MULTIPLE data products by ID in a single call "
"(including output ports / physical table locations). Prefer this over "
"repeated get_data_product when you already hold a set of ids. Returns up "
"to 20 by default; raise 'limit' up to 100. Ids beyond the limit are not "
"fetched and reported via 'truncated'/'requested' so you can page."
"(including output ports / physical table locations, imported ODPS team "
"members with roles, lifecycle timestamps, and the preserved original "
"id from the source YAML). Prefer this over repeated get_data_product "
"when you already hold a set of ids. Returns up to 20 by default; raise "
"'limit' up to 100. Ids beyond the limit are not fetched and reported "
"via 'truncated'/'requested' so you can page."
)
parameters = {
"product_ids": {
Expand Down