Skip to content

Commit 35157f2

Browse files
luis-dkclaude
andcommitted
feat(mcp): accept hygiene issue id in source data tools
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 48f0235 commit 35157f2

6 files changed

Lines changed: 388 additions & 59 deletions

File tree

testgen/mcp/tools/common.py

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
ProfileMetric,
1818
SuggestedDataType,
1919
)
20-
from testgen.common.models.hygiene_issue import HygieneIssueType
20+
from testgen.common.models.hygiene_issue import HygieneIssue, HygieneIssueType
2121
from testgen.common.models.notification_settings import (
2222
MonitorNotificationTrigger,
2323
NotificationEvent,
@@ -524,6 +524,16 @@ def resolve_table_group(table_group_id: str) -> TableGroup:
524524
return tg
525525

526526

527+
def resolve_hygiene_issue(issue_id: str) -> HygieneIssue:
528+
"""Resolve a hygiene issue ID, collapsing missing-or-inaccessible into one error path."""
529+
issue_uuid = parse_uuid(issue_id, "issue_id")
530+
perms = get_project_permissions()
531+
issue = HygieneIssue.get(issue_uuid, HygieneIssue.project_code.in_(perms.allowed_codes))
532+
if issue is None:
533+
raise MCPResourceNotAccessible("Hygiene issue", issue_id)
534+
return issue
535+
536+
527537
def resolve_test_suite(test_suite_id: str) -> TestSuite:
528538
"""Resolve a regular (non-monitor) test suite ID, collapsing missing-or-inaccessible into one error path."""
529539
suite_uuid = parse_uuid(test_suite_id, "test_suite_id")

testgen/mcp/tools/hygiene_issues.py

Lines changed: 3 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
parse_quality_dimension,
2626
parse_since_arg,
2727
parse_uuid,
28+
resolve_hygiene_issue,
2829
resolve_issue_type,
2930
resolve_table_group,
3031
validate_limit,
@@ -211,17 +212,9 @@ def update_hygiene_issue(*, issue_id: str, disposition: str) -> str:
211212
issue_id: UUID of the hygiene issue.
212213
disposition: New disposition. Valid values: 'Confirmed', 'Dismissed', 'Muted'.
213214
"""
214-
issue_uuid = parse_uuid(issue_id, "issue_id")
215215
db_disposition = parse_disposition(disposition)
216-
perms = get_project_permissions()
217-
218-
updated = HygieneIssue.update_disposition(
219-
issue_uuid,
220-
db_disposition,
221-
HygieneIssue.project_code.in_(perms.allowed_codes),
222-
)
223-
if not updated:
224-
raise MCPResourceNotAccessible("Hygiene issue", issue_id)
216+
issue = resolve_hygiene_issue(issue_id)
217+
issue.disposition = db_disposition
225218

226219
doc = MdDoc()
227220
doc.text(f"Updated hygiene issue {MdDoc.code(issue_id)} disposition to **{disposition}**.")

testgen/mcp/tools/source_data.py

Lines changed: 97 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,34 @@
11
from datetime import datetime
22

33
from testgen.common.models import with_database_session
4+
from testgen.common.models.profiling_run import ProfilingRun
45
from testgen.common.models.test_definition import TestDefinition
56
from testgen.common.source_data_service import (
67
SourceDataResult,
8+
build_hygiene_query,
79
build_test_result_query,
10+
fetch_hygiene_source_data,
811
fetch_test_result_source_data,
912
)
1013
from testgen.mcp.exceptions import MCPResourceNotAccessible, MCPUserError
1114
from testgen.mcp.permissions import get_project_permissions, mcp_permission
12-
from testgen.mcp.tools.common import DocGroup, parse_uuid, validate_limit
15+
from testgen.mcp.tools.common import DocGroup, parse_uuid, resolve_hygiene_issue, validate_limit
1316
from testgen.mcp.tools.markdown import MdDoc
1417

1518
_DOC_GROUP = DocGroup.INVESTIGATE
1619

1720

18-
def _resolve_context(test_definition_id: str, reference_date: str | None) -> dict:
21+
def _validate_source_args(test_definition_id: str | None, issue_id: str | None, reference_date: str | None) -> None:
22+
"""Enforce 'exactly one entity' and the reference_date-only-with-test-definition rule."""
23+
if bool(test_definition_id) == bool(issue_id):
24+
raise MCPUserError("Provide exactly one of test_definition_id or issue_id.")
25+
if issue_id and reference_date:
26+
raise MCPUserError(
27+
"reference_date applies only to test_definition_id; omit it when looking up a hygiene issue."
28+
)
29+
30+
31+
def _resolve_test_definition_context(test_definition_id: str, reference_date: str | None) -> dict:
1932
"""Look up the test definition context and validate permissions."""
2033
td_uuid = parse_uuid(test_definition_id, "test_definition_id")
2134
perms = get_project_permissions()
@@ -40,40 +53,86 @@ def _resolve_context(test_definition_id: str, reference_date: str | None) -> dic
4053
return context
4154

4255

56+
def _resolve_hygiene_context(issue_id: str) -> dict:
57+
"""Resolve a hygiene issue (permission-scoped) into the lookup context the service expects.
58+
59+
The source profiling run is intrinsic to the issue, so ``profiling_starttime`` comes from the
60+
issue's ``ProfilingRun`` — there is no caller-supplied reference date.
61+
"""
62+
issue = resolve_hygiene_issue(issue_id)
63+
run = ProfilingRun.get(issue.profile_run_id)
64+
return {
65+
"table_groups_id": issue.table_groups_id,
66+
"anomaly_id": issue.type_id,
67+
"detail": issue.detail,
68+
"schema_name": issue.schema_name,
69+
"table_name": issue.table_name,
70+
"column_name": issue.column_name,
71+
"profiling_starttime": run.profiling_starttime if run else None,
72+
"project_code": issue.project_code,
73+
}
74+
75+
76+
def _render_header_fields(doc: MdDoc, context: dict) -> None:
77+
"""Render the entity-neutral location fields shared by both tools."""
78+
if context.get("test_type"):
79+
doc.field("Test type", context.get("test_type"), code=True)
80+
doc.field("Table", f"{context.get('schema_name')}.{context.get('table_name')}", code=True)
81+
column = context.get("column_names") or context.get("column_name")
82+
if column:
83+
doc.field("Column", column, code=True)
84+
85+
4386
@with_database_session
4487
@mcp_permission("view")
4588
def get_source_data_query(
46-
test_definition_id: str,
89+
test_definition_id: str | None = None,
90+
issue_id: str | None = None,
4791
reference_date: str | None = None,
4892
limit: int = 100,
4993
) -> str:
50-
"""Get the SQL query that would be used to look up source data for a test definition, without executing it.
94+
"""Get the SQL query that would be used to look up source data, without executing it.
5195
52-
Builds a lookup query using current test definition parameters (thresholds, conditions).
96+
Builds a lookup query using the current criteria of a test definition or a hygiene issue.
5397
The query targets the connected database.
5498
Some test types (e.g. Freshness Trend, Schema Drift) do not have source data lookups.
5599
100+
Provide exactly one of ``test_definition_id`` or ``issue_id``.
101+
56102
Args:
57103
test_definition_id: UUID of a test definition, e.g. from ``list_test_results``.
58-
reference_date: ISO 8601 date used as the test reference point (default: now).
104+
issue_id: UUID of a hygiene issue, e.g. from ``list_hygiene_issues``. Mutually exclusive
105+
with ``test_definition_id``.
106+
reference_date: ISO 8601 date used as the test reference point (default: now). Applies only
107+
to ``test_definition_id``.
59108
limit: Maximum rows the query would return (default 100, max 500).
60109
"""
110+
_validate_source_args(test_definition_id, issue_id, reference_date)
61111
validate_limit(limit, 500)
62-
context = _resolve_context(test_definition_id, reference_date)
63112

64-
query = build_test_result_query(context, limit)
113+
if test_definition_id:
114+
context = _resolve_test_definition_context(test_definition_id, reference_date)
115+
entity_label, entity_id = "Test Definition", test_definition_id
116+
query = build_test_result_query(context, limit)
117+
else:
118+
context = _resolve_hygiene_context(issue_id)
119+
entity_label, entity_id = "Hygiene Issue", issue_id
120+
query = build_hygiene_query(context, limit)
121+
65122
if not query:
123+
if test_definition_id:
124+
return (
125+
f"Source data lookup is not available for test type `{context.get('test_type', 'unknown')}`.\n\n"
126+
"This test type does not have a defined lookup query."
127+
)
66128
return (
67-
f"Source data lookup is not available for test type `{context.get('test_type', 'unknown')}`.\n\n"
68-
"This test type does not have a defined lookup query."
129+
"Source data lookup is not available for this hygiene issue.\n\n"
130+
"This hygiene issue type does not have a defined lookup query."
69131
)
70132

71133
doc = MdDoc()
72-
doc.heading(1, f"Source Data Query for Test Definition `{test_definition_id}`")
73-
doc.field("Test type", context.get("test_type"), code=True)
74-
doc.field("Table", f"{context.get('schema_name')}.{context.get('table_name')}", code=True)
75-
if context.get("column_names"):
76-
doc.field("Column", context["column_names"], code=True)
134+
doc.heading(1, f"Source Data Query for {entity_label} `{entity_id}`")
135+
_render_header_fields(doc, context)
77136
doc.field("Limit", limit)
78137
doc.code_block(query, language="sql")
79138

@@ -83,34 +142,46 @@ def get_source_data_query(
83142
@with_database_session
84143
@mcp_permission("view")
85144
def get_source_data(
86-
test_definition_id: str,
145+
test_definition_id: str | None = None,
146+
issue_id: str | None = None,
87147
reference_date: str | None = None,
88148
limit: int = 100,
89149
) -> str:
90-
"""Look up rows from the connected database that match or violate a test definition's criteria.
150+
"""Look up rows from the connected database that match or violate a test or hygiene issue's criteria.
91151
92152
Executes the source data query against the connected database and returns matching rows.
93-
Shows CURRENT data — rows may have changed since the test last ran.
153+
Shows CURRENT data — rows may have changed since the test or profiling run.
94154
Some test types (e.g. Freshness Trend, Schema Drift) do not have source data lookups.
95155
156+
Provide exactly one of ``test_definition_id`` or ``issue_id``.
157+
96158
Args:
97159
test_definition_id: UUID of a test definition, e.g. from ``list_test_results``.
98-
reference_date: ISO 8601 date used as the test reference point (default: now).
160+
issue_id: UUID of a hygiene issue, e.g. from ``list_hygiene_issues``. Mutually exclusive
161+
with ``test_definition_id``.
162+
reference_date: ISO 8601 date used as the test reference point (default: now). Applies only
163+
to ``test_definition_id``.
99164
limit: Maximum rows to return (default 100, max 500).
100165
"""
166+
_validate_source_args(test_definition_id, issue_id, reference_date)
101167
validate_limit(limit, 500)
102-
context = _resolve_context(test_definition_id, reference_date)
168+
169+
if test_definition_id:
170+
context = _resolve_test_definition_context(test_definition_id, reference_date)
171+
entity_label, entity_id = "Test Definition", test_definition_id
172+
fetch = fetch_test_result_source_data
173+
else:
174+
context = _resolve_hygiene_context(issue_id)
175+
entity_label, entity_id = "Hygiene Issue", issue_id
176+
fetch = fetch_hygiene_source_data
103177

104178
mask_pii = not get_project_permissions().has_permission("view_pii", context.get("project_code"))
105179

106-
result: SourceDataResult = fetch_test_result_source_data(context, limit, mask_pii)
180+
result: SourceDataResult = fetch(context, limit, mask_pii)
107181

108182
doc = MdDoc()
109-
doc.heading(1, f"Source Data for Test Definition `{test_definition_id}`")
110-
doc.field("Test type", context.get("test_type"), code=True)
111-
doc.field("Table", f"{context.get('schema_name')}.{context.get('table_name')}", code=True)
112-
if context.get("column_names"):
113-
doc.field("Column", context["column_names"], code=True)
183+
doc.heading(1, f"Source Data for {entity_label} `{entity_id}`")
184+
_render_header_fields(doc, context)
114185

115186
if result.status == "OK":
116187
row_count = len(result.df) if result.df is not None else 0

tests/unit/mcp/test_tools_common.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
parse_score_group_by,
3030
parse_score_type,
3131
parse_uuid,
32+
resolve_hygiene_issue,
3233
resolve_issue_type,
3334
resolve_profiling_run,
3435
resolve_test_note,
@@ -379,6 +380,35 @@ def test_resolve_test_note_invalid_uuid():
379380
resolve_test_note("not-a-uuid")
380381

381382

383+
# --- resolve_hygiene_issue ---
384+
385+
386+
@patch("testgen.mcp.tools.common.get_project_permissions")
387+
@patch("testgen.mcp.tools.common.HygieneIssue")
388+
def test_resolve_hygiene_issue_happy_path(mock_hi_cls, mock_get_perms, db_session_mock):
389+
issue = MagicMock()
390+
mock_hi_cls.get.return_value = issue
391+
mock_get_perms.return_value = _mock_perms()
392+
393+
assert resolve_hygiene_issue(str(uuid4())) is issue
394+
395+
396+
@patch("testgen.mcp.tools.common.get_project_permissions")
397+
@patch("testgen.mcp.tools.common.HygieneIssue")
398+
def test_resolve_hygiene_issue_missing_or_inaccessible(mock_hi_cls, mock_get_perms, db_session_mock):
399+
"""Missing issue and forbidden-project issue both collapse to one error (project scoped in the query)."""
400+
mock_hi_cls.get.return_value = None
401+
mock_get_perms.return_value = _mock_perms()
402+
403+
with pytest.raises(MCPResourceNotAccessible, match=r"Hygiene issue .* not found or not accessible"):
404+
resolve_hygiene_issue(str(uuid4()))
405+
406+
407+
def test_resolve_hygiene_issue_invalid_uuid():
408+
with pytest.raises(MCPUserError, match="Invalid issue_id"):
409+
resolve_hygiene_issue("not-a-uuid")
410+
411+
382412
# --- parse_pii_category ---
383413

384414

tests/unit/mcp/test_tools_hygiene_issues.py

Lines changed: 34 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -727,22 +727,35 @@ def test_update_hygiene_issue_invalid_disposition(db_session_mock, disposition_p
727727
update_hygiene_issue(issue_id=str(uuid4()), disposition="Bogus")
728728

729729

730-
@patch.object(HygieneIssue, "update_disposition")
731-
def test_update_hygiene_issue_muted_maps_to_inactive(mock_update, db_session_mock, disposition_perms):
730+
@patch("testgen.mcp.tools.hygiene_issues.resolve_hygiene_issue")
731+
def test_update_hygiene_issue_muted_maps_to_inactive(mock_resolve, db_session_mock, disposition_perms):
732732
from testgen.mcp.tools.hygiene_issues import update_hygiene_issue
733733

734-
mock_update.return_value = True
734+
issue = MagicMock()
735+
mock_resolve.return_value = issue
735736
update_hygiene_issue(issue_id=str(uuid4()), disposition="Muted")
736737

737-
args = mock_update.call_args.args
738-
assert args[1] == Disposition.INACTIVE
738+
assert issue.disposition == Disposition.INACTIVE
739739

740740

741-
@patch.object(HygieneIssue, "update_disposition")
742-
def test_update_hygiene_issue_returns_success_markdown(mock_update, db_session_mock, disposition_perms):
741+
@patch("testgen.mcp.tools.hygiene_issues.resolve_hygiene_issue")
742+
def test_update_hygiene_issue_sets_disposition_on_resolved_issue(
743+
mock_resolve, db_session_mock, disposition_perms,
744+
):
745+
from testgen.mcp.tools.hygiene_issues import update_hygiene_issue
746+
747+
issue = MagicMock()
748+
mock_resolve.return_value = issue
749+
update_hygiene_issue(issue_id=str(uuid4()), disposition="Dismissed")
750+
751+
assert issue.disposition == Disposition.DISMISSED
752+
753+
754+
@patch("testgen.mcp.tools.hygiene_issues.resolve_hygiene_issue")
755+
def test_update_hygiene_issue_returns_success_markdown(mock_resolve, db_session_mock, disposition_perms):
743756
from testgen.mcp.tools.hygiene_issues import update_hygiene_issue
744757

745-
mock_update.return_value = True
758+
mock_resolve.return_value = MagicMock()
746759
issue_id = str(uuid4())
747760
result = update_hygiene_issue(issue_id=issue_id, disposition="Dismissed")
748761

@@ -751,30 +764,30 @@ def test_update_hygiene_issue_returns_success_markdown(mock_update, db_session_m
751764
assert "Dismissed" in result
752765

753766

754-
@patch.object(HygieneIssue, "update_disposition")
755-
def test_update_hygiene_issue_not_updated_collapses_to_not_accessible(
756-
mock_update, db_session_mock, disposition_perms,
767+
@patch("testgen.mcp.tools.hygiene_issues.resolve_hygiene_issue")
768+
def test_update_hygiene_issue_not_accessible_propagates(
769+
mock_resolve, db_session_mock, disposition_perms,
757770
):
758771
from testgen.mcp.tools.hygiene_issues import update_hygiene_issue
759772

760-
mock_update.return_value = False
773+
mock_resolve.side_effect = MCPResourceNotAccessible("Hygiene issue", "x")
761774
with pytest.raises(MCPResourceNotAccessible):
762775
update_hygiene_issue(issue_id=str(uuid4()), disposition="Confirmed")
763776

764777

765-
@patch.object(HygieneIssue, "update_disposition")
766-
def test_update_hygiene_issue_passes_project_scope_clause(
767-
mock_update, db_session_mock, disposition_perms,
778+
@patch("testgen.mcp.tools.hygiene_issues.resolve_hygiene_issue")
779+
def test_update_hygiene_issue_delegates_scope_to_resolver(
780+
mock_resolve, db_session_mock, disposition_perms,
768781
):
782+
"""Project scoping lives in resolve_hygiene_issue (covered in test_tools_common);
783+
the tool must route the issue id through it."""
769784
from testgen.mcp.tools.hygiene_issues import update_hygiene_issue
770785

771-
mock_update.return_value = True
772-
update_hygiene_issue(issue_id=str(uuid4()), disposition="Confirmed")
786+
mock_resolve.return_value = MagicMock()
787+
issue_id = str(uuid4())
788+
update_hygiene_issue(issue_id=issue_id, disposition="Confirmed")
773789

774-
# Trailing args after (issue_uuid, db_disposition) are the *clauses
775-
clauses = mock_update.call_args.args[2:]
776-
sql = "\n".join(str(c.compile(dialect=postgresql.dialect())) for c in clauses)
777-
assert "profile_anomaly_results.project_code IN" in sql
790+
mock_resolve.assert_called_once_with(issue_id)
778791

779792

780793
def test_update_hygiene_issue_uses_disposition_permission():

0 commit comments

Comments
 (0)