Skip to content

Commit 01124a6

Browse files
luis-dkclaude
andcommitted
feat(mcp): add test-result disposition tools
Add update_test_result and bulk_update_test_results to disposition test results (confirm, dismiss, mute, or clear), gated on the disposition permission and reusing the shared disposition service. Adds the parse_test_result_disposition and resolve_test_result helpers, surfaces test_result_id in list_test_results, and registers both tools. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 3133a1f commit 01124a6

5 files changed

Lines changed: 481 additions & 2 deletions

File tree

testgen/mcp/server.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -220,12 +220,14 @@ def build_mcp_server(
220220
validate_custom_test,
221221
)
222222
from testgen.mcp.tools.test_results import (
223+
bulk_update_test_results,
223224
compare_test_runs,
224225
get_failure_summary,
225226
get_failure_trend,
226227
list_test_result_history,
227228
list_test_results,
228229
search_test_results,
230+
update_test_result,
229231
)
230232
from testgen.mcp.tools.test_runs import get_test_run, list_test_runs
231233

@@ -266,6 +268,8 @@ def safe_prompt(fn):
266268
safe_tool(search_test_results)
267269
safe_tool(get_failure_trend)
268270
safe_tool(compare_test_runs)
271+
safe_tool(update_test_result)
272+
safe_tool(bulk_update_test_results)
269273
safe_tool(get_test_type)
270274
safe_tool(get_source_data)
271275
safe_tool(get_source_data_query)

testgen/mcp/tools/common.py

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@
3030
from testgen.common.models.scores import ScoreCategory, ScoreDefinition
3131
from testgen.common.models.table_group import TableGroup
3232
from testgen.common.models.test_definition import TestDefinition, TestDefinitionNote, TestType
33-
from testgen.common.models.test_result import TestResultStatus
33+
from testgen.common.models.test_result import TestResult, TestResultStatus
3434
from testgen.common.models.test_suite import TestSuite
3535
from testgen.mcp.exceptions import MCPResourceNotAccessible, MCPUserError
3636
from testgen.mcp.permissions import get_project_permissions
@@ -353,6 +353,25 @@ def parse_disposition(value: str) -> Disposition:
353353
return db_value
354354

355355

356+
_NO_DECISION = "No Decision"
357+
358+
359+
def parse_test_result_disposition(value: str) -> Disposition | None:
360+
"""Validate a user-facing test-result disposition and return the stored value.
361+
362+
Accepts ``Confirmed``, ``Dismissed``, ``Muted``, and ``No Decision``. ``Muted``
363+
maps to ``Disposition.INACTIVE``; ``No Decision`` clears the disposition (returns
364+
``None`` → NULL).
365+
"""
366+
if value == _NO_DECISION:
367+
return None
368+
db_value = _DISPOSITION_USER_TO_DB.get(value)
369+
if db_value is None:
370+
valid = ", ".join([*_DISPOSITION_USER_TO_DB, _NO_DECISION])
371+
raise MCPUserError(f"Invalid disposition `{value}`. Valid values: {valid}")
372+
return db_value
373+
374+
356375
def format_disposition(value: Disposition | str) -> str:
357376
"""Map a stored disposition to its user-facing label (``INACTIVE`` → "Muted")."""
358377
try:
@@ -623,6 +642,28 @@ def resolve_test_definition(test_definition_id: str) -> TestDefinition:
623642
return td
624643

625644

645+
def resolve_test_result(test_result_id: str) -> TestResult:
646+
"""Resolve a test result ID to the live ORM model, collapsing missing-or-inaccessible.
647+
648+
Filters monitor suites and project access via the result's parent test suite.
649+
"""
650+
result_uuid = parse_uuid(test_result_id, "test_result_id")
651+
perms = get_project_permissions()
652+
query = (
653+
select(TestResult)
654+
.join(TestSuite, TestResult.test_suite_id == TestSuite.id)
655+
.where(
656+
TestResult.id == result_uuid,
657+
TestSuite.is_monitor.isnot(True),
658+
TestSuite.project_code.in_(perms.allowed_codes),
659+
)
660+
)
661+
result = get_current_session().scalars(query).first()
662+
if result is None:
663+
raise MCPResourceNotAccessible("Test result", test_result_id)
664+
return result
665+
666+
626667
def resolve_test_note(test_note_id: str) -> TestDefinitionNote:
627668
"""Resolve a test note ID to the live ORM model, collapsing missing-or-inaccessible.
628669

testgen/mcp/tools/test_results.py

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,19 @@
11
from datetime import UTC, datetime, timedelta
22
from uuid import UUID
33

4+
from sqlalchemy import select
5+
46
from testgen.common.enums import JobStatus
57
from testgen.common.models import get_current_session, with_database_session
68
from testgen.common.models.job_execution import JobExecution
79
from testgen.common.models.test_definition import TestType
810
from testgen.common.models.test_result import BucketInterval, TestResult, TestResultStatus
911
from testgen.common.models.test_run import TestRun, TestRunSummary
1012
from testgen.common.models.test_suite import TestSuite
13+
from testgen.common.test_result_disposition_service import (
14+
DispositionUpdate,
15+
set_test_results_disposition,
16+
)
1117
from testgen.mcp.exceptions import MCPResourceNotAccessible, MCPUserError
1218
from testgen.mcp.permissions import get_project_permissions, mcp_permission
1319
from testgen.mcp.tools.common import (
@@ -18,8 +24,11 @@
1824
parse_failure_group_by,
1925
parse_result_status,
2026
parse_since_arg,
27+
parse_test_result_disposition,
2128
parse_uuid,
2229
resolve_aggregate_scope,
30+
resolve_test_result,
31+
resolve_test_suite,
2332
resolve_test_type,
2433
validate_limit,
2534
validate_page,
@@ -134,6 +143,7 @@ def list_test_results(
134143
doc.heading(2, f"[{status_str}] {test_name} on `{r.column_names}` in `{r.table_name}`")
135144
else:
136145
doc.heading(2, f"[{status_str}] {test_name} on `{r.table_name}`")
146+
doc.field("Test result", r.id, code=True)
137147
doc.field("Test definition", r.test_definition_id, code=True)
138148
if r.column_names:
139149
doc.field("Column", r.column_names, code=True)
@@ -636,3 +646,116 @@ def _section(title: str, rows: list) -> None:
636646
_section("Removed Tests", diff.removed_tests)
637647

638648
return doc.render()
649+
650+
651+
@with_database_session
652+
@mcp_permission("disposition")
653+
def update_test_result(test_result_id: str, disposition: str) -> str:
654+
"""Set the disposition on a single test result (confirm, dismiss, mute, or clear).
655+
656+
Args:
657+
test_result_id: UUID of the test result, e.g. from ``list_test_results``.
658+
disposition: New disposition. One of 'Confirmed', 'Dismissed', 'Muted',
659+
'No Decision' (clears it). 'Muted' deactivates the parent test and locks
660+
it against auto-regeneration; any other value reactivates and unlocks it.
661+
"""
662+
result = resolve_test_result(test_result_id)
663+
db_disposition = parse_test_result_disposition(disposition)
664+
665+
update: DispositionUpdate = set_test_results_disposition([result.id], db_disposition)
666+
667+
doc = MdDoc()
668+
if update.matched == 0:
669+
doc.text(
670+
f"Test result {MdDoc.code(test_result_id)} was not dispositioned — disposition does "
671+
f"not apply to passed results. No change made."
672+
)
673+
return doc.render()
674+
675+
doc.text(f"Updated test result {MdDoc.code(test_result_id)} disposition to **{disposition}**.")
676+
return doc.render()
677+
678+
679+
@with_database_session
680+
@mcp_permission("disposition")
681+
def bulk_update_test_results(
682+
test_suite_id: str,
683+
disposition: str,
684+
job_execution_id: str | None = None,
685+
table_name: str | None = None,
686+
test_type: str | None = None,
687+
status: str | None = None,
688+
test_definition_id: str | None = None,
689+
) -> str:
690+
"""Set the disposition on every matching test result in a suite (confirm, dismiss, mute, clear).
691+
692+
Args:
693+
test_suite_id: UUID of the test suite, e.g. from ``list_test_suites``.
694+
disposition: New disposition. One of 'Confirmed', 'Dismissed', 'Muted',
695+
'No Decision' (clears it). 'Muted' deactivates the parent tests and locks
696+
them against auto-regeneration; any other value reactivates and unlocks.
697+
job_execution_id: UUID of a test run within the suite. Defaults to the suite's
698+
latest completed run when omitted.
699+
table_name: Optional table-name filter. Case-sensitive.
700+
test_type: Optional test type name (e.g. 'Alpha Truncation').
701+
status: Optional result-status filter (Passed, Failed, Warning, Error, Log).
702+
test_definition_id: Optional single test-definition filter.
703+
"""
704+
suite = resolve_test_suite(test_suite_id)
705+
db_disposition = parse_test_result_disposition(disposition)
706+
707+
if job_execution_id:
708+
run = TestRun.get_by_id_or_job(parse_uuid(job_execution_id, "job_execution_id"))
709+
if run is None or run.test_suite_id != suite.id:
710+
raise MCPResourceNotAccessible("Test run", job_execution_id)
711+
else:
712+
run = (
713+
TestRun.get_by_id_or_job(suite.last_complete_test_run_id)
714+
if suite.last_complete_test_run_id
715+
else None
716+
)
717+
if run is None:
718+
raise MCPUserError(f"No completed test runs found for test suite `{test_suite_id}`.")
719+
720+
clauses = [TestResult.test_suite_id == suite.id, TestResult.test_run_id == run.id]
721+
if status:
722+
clauses.append(TestResult.status == parse_result_status(status))
723+
if table_name:
724+
clauses.append(TestResult.table_name == table_name)
725+
if test_type:
726+
clauses.append(TestResult.test_type == resolve_test_type(test_type))
727+
if test_definition_id:
728+
clauses.append(TestResult.test_definition_id == parse_uuid(test_definition_id, "test_definition_id"))
729+
730+
result_ids = list(get_current_session().scalars(select(TestResult.id).where(*clauses)).all())
731+
update = set_test_results_disposition(result_ids, db_disposition)
732+
733+
filters = []
734+
if table_name:
735+
filters.append(f"table_name=`{table_name}`")
736+
if test_type:
737+
filters.append(f"test_type=`{test_type}`")
738+
if status:
739+
filters.append(f"status=`{status}`")
740+
if test_definition_id:
741+
filters.append(f"test_definition_id=`{test_definition_id}`")
742+
filter_str = ", ".join(filters) if filters else "no filter"
743+
744+
doc = MdDoc()
745+
if update.matched == 0 and update.passed_skipped == 0:
746+
doc.heading(1, "No test results matched")
747+
doc.text(
748+
f"No test results in suite `{suite.test_suite}` matched the filter ({filter_str}). Nothing changed."
749+
)
750+
return doc.render()
751+
752+
doc.heading(1, f"Updated {update.matched} test results in suite `{suite.test_suite}`")
753+
doc.field("Disposition", disposition)
754+
doc.field("Run", run.job_execution_id, code=True)
755+
doc.field("Filter", filter_str)
756+
if update.passed_skipped:
757+
doc.text(
758+
f"Left {update.passed_skipped} passed results unchanged — disposition is not "
759+
f"applied to passed results."
760+
)
761+
return doc.render()

tests/unit/mcp/test_tools_common.py

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -772,3 +772,65 @@ def test_parse_sql_flavor_invalid_lists_display_values():
772772
msg = str(exc.value)
773773
for member in SqlFlavorLabel:
774774
assert member.value in msg
775+
776+
777+
# --- parse_test_result_disposition ---
778+
779+
780+
@pytest.mark.parametrize(
781+
"user_label,expected",
782+
[
783+
("Confirmed", Disposition.CONFIRMED),
784+
("Dismissed", Disposition.DISMISSED),
785+
("Muted", Disposition.INACTIVE),
786+
("No Decision", None),
787+
],
788+
)
789+
def test_parse_test_result_disposition_user_labels(user_label, expected):
790+
from testgen.mcp.tools.common import parse_test_result_disposition
791+
792+
assert parse_test_result_disposition(user_label) is expected
793+
794+
795+
def test_parse_test_result_disposition_rejects_unknown_and_lists_accepted():
796+
from testgen.mcp.tools.common import parse_test_result_disposition
797+
798+
with pytest.raises(MCPUserError) as exc:
799+
parse_test_result_disposition("Inactive") # DB value, not user-facing
800+
msg = str(exc.value)
801+
for label in ("Confirmed", "Dismissed", "Muted", "No Decision"):
802+
assert label in msg
803+
804+
805+
# --- resolve_test_result ---
806+
807+
808+
@patch("testgen.mcp.tools.common.get_project_permissions")
809+
@patch("testgen.mcp.tools.common.get_current_session")
810+
def test_resolve_test_result_happy_path(mock_session, mock_perms, db_session_mock):
811+
from testgen.mcp.tools.common import resolve_test_result
812+
813+
result = MagicMock()
814+
mock_session.return_value.scalars.return_value.first.return_value = result
815+
mock_perms.return_value = _mock_perms(allowed_projects=("demo",))
816+
817+
assert resolve_test_result(str(uuid4())) is result
818+
819+
820+
@patch("testgen.mcp.tools.common.get_project_permissions")
821+
@patch("testgen.mcp.tools.common.get_current_session")
822+
def test_resolve_test_result_missing_or_inaccessible(mock_session, mock_perms, db_session_mock):
823+
from testgen.mcp.tools.common import resolve_test_result
824+
825+
mock_session.return_value.scalars.return_value.first.return_value = None
826+
mock_perms.return_value = _mock_perms(allowed_projects=("demo",))
827+
828+
with pytest.raises(MCPResourceNotAccessible, match=r"Test result .* not found or not accessible"):
829+
resolve_test_result(str(uuid4()))
830+
831+
832+
def test_resolve_test_result_invalid_uuid():
833+
from testgen.mcp.tools.common import resolve_test_result
834+
835+
with pytest.raises(MCPUserError, match="Invalid test_result_id"):
836+
resolve_test_result("not-a-uuid")

0 commit comments

Comments
 (0)