Skip to content

Commit 3a5bac8

Browse files
diogodkclaude
authored andcommitted
refactor(mcp): lift render_diff_table + resolve_project to common (TG-1070 review S5)
The diff-table render loop was duplicated three places (table_groups, projects, test_suites). The verify_access + Project.get + None-check trio in update_project also duplicated the resolve_* idiom already used for connections, table groups, and test suites. Both moves were flagged in the TG-1070 review. - render_diff_table(doc, before, after, *, attrs, labels, secret_attrs, value_renderer) in common.py owns the changed-attrs iteration, label lookup, secret redaction, and table emit. Each tool retains its own _DIFF_ORDER / _DIFF_LABELS and its own snapshot function (different shapes per entity). - resolve_project(project_code) joins the existing resolve_* family. Uses Project.get(code, Project.project_code.in_(allowed_codes)) so the unified not-found-or-not-accessible error path matches resolve_test_suite and resolve_table_group: out-of-scope projects never reveal whether they exist. update_connection keeps its own custom diff renderer — the "[secret] (rotated)" cue it emits for secret writes is semantically distinct from the generic "[secret]" redaction and isn't worth bending the helper to model both. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent fbd021f commit 3a5bac8

6 files changed

Lines changed: 346 additions & 118 deletions

File tree

testgen/mcp/tools/common.py

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
from collections.abc import Callable, Mapping, Sequence
12
from dataclasses import dataclass, field
23
from datetime import date, datetime
34
from enum import StrEnum
@@ -35,6 +36,7 @@
3536
TestRunNotificationTrigger,
3637
)
3738
from testgen.common.models.profiling_run import ProfilingRun
39+
from testgen.common.models.project import Project
3840
from testgen.common.models.scheduler import SCHEDULABLE_JOB_KEYS, JobSchedule
3941
from testgen.common.models.scores import ScoreCategory, ScoreDefinition
4042
from testgen.common.models.table_group import TableGroup
@@ -628,10 +630,89 @@ def format_page_footer(total: int, page: int, limit: int) -> str:
628630
return f"_Page {page} of {total_pages}. Use `page={page + 1}` for more._"
629631

630632

633+
def _default_render_diff_value(value: object) -> str | None:
634+
"""Default formatter for before/after cells in :func:`render_diff_table`.
635+
636+
* ``bool`` → ``"Yes"`` / ``"No"``
637+
* ``None`` or ``""`` → ``None`` (rendered as em-dash by the table cell)
638+
* else → ``str(value)``
639+
"""
640+
if isinstance(value, bool):
641+
return "Yes" if value else "No"
642+
if value is None or value == "":
643+
return None
644+
return str(value)
645+
646+
647+
def render_diff_table(
648+
doc: MdDoc,
649+
before: Mapping[str, object],
650+
after: Mapping[str, object],
651+
*,
652+
attrs: Sequence[str],
653+
labels: Mapping[str, str],
654+
secret_attrs: frozenset[str] = frozenset(),
655+
value_renderer: Callable[[object], str | None] | None = None,
656+
) -> bool:
657+
"""Emit a ``Field / Before / After`` table for attrs whose values changed.
658+
659+
Update tools across the MCP surface share this shape: snapshot before, apply
660+
field-by-field, snapshot after, render the delta. The shared helper keeps the
661+
ordering, label lookup, and secret redaction consistent.
662+
663+
Args:
664+
doc: ``MdDoc`` to append the table to.
665+
before / after: same-shape dicts of the snapshot keyed by attr name.
666+
Only entries in ``attrs`` are inspected.
667+
attrs: ordered tuple of attrs to consider; controls row order.
668+
labels: attr → user-facing label for the first column.
669+
secret_attrs: attrs whose values must not be echoed (API keys, passwords).
670+
Rendered as ``[secret]`` when present, em-dash when absent — distinct
671+
from a "rotated" cue. Tools that need to communicate rotation
672+
specifically (e.g. ``update_connection``) keep their own renderer.
673+
value_renderer: override for the non-secret cells. Defaults to
674+
:func:`_default_render_diff_value`.
675+
676+
Returns ``True`` if at least one row was rendered, ``False`` when nothing
677+
changed (lets the caller emit a "No fields changed" message instead).
678+
"""
679+
changed = {attr for attr in attrs if before.get(attr) != after.get(attr)}
680+
if not changed:
681+
return False
682+
render = value_renderer or _default_render_diff_value
683+
rows: list[list[object]] = []
684+
for attr in attrs:
685+
if attr not in changed:
686+
continue
687+
if attr in secret_attrs:
688+
b = "[secret]" if before.get(attr) else None
689+
a = "[secret]" if after.get(attr) else None
690+
else:
691+
b = render(before.get(attr))
692+
a = render(after.get(attr))
693+
rows.append([labels.get(attr, attr), b, a])
694+
doc.table(["Field", "Before", "After"], rows, code=[0])
695+
return True
696+
697+
631698
# Entity resolution helpers — see mcp-roadmap.md "Entity Resolution Helpers" guideline.
632699
# Extract a new resolve_<entity> here when a second caller needs the same parse-uuid +
633700
# perm-scoped lookup + collapsed-error pattern.
634701

702+
703+
def resolve_project(project_code: str) -> Project:
704+
"""Resolve a project code to a Project, scoped to the user's allowed projects.
705+
706+
Collapses missing-or-inaccessible into a single ``MCPResourceNotAccessible`` so
707+
callers can't enumerate whether a project they can't see exists.
708+
"""
709+
perms = get_project_permissions()
710+
project = Project.get(project_code, Project.project_code.in_(perms.allowed_codes))
711+
if project is None:
712+
raise MCPResourceNotAccessible("Project", project_code)
713+
return project
714+
715+
635716
def resolve_connection(connection_id: int) -> Connection:
636717
"""Resolve a connection ID, collapsing missing-or-inaccessible into one error path."""
637718
perms = get_project_permissions()

testgen/mcp/tools/projects.py

Lines changed: 15 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -18,9 +18,14 @@
1818
DEFAULT_RETENTION_CRON_TZ,
1919
JobSchedule,
2020
)
21-
from testgen.mcp.exceptions import MCPResourceNotAccessible, MCPUserError
22-
from testgen.mcp.permissions import get_project_permissions, mcp_permission
23-
from testgen.mcp.tools.common import DocGroup, raise_validation_error
21+
from testgen.mcp.exceptions import MCPUserError
22+
from testgen.mcp.permissions import mcp_permission
23+
from testgen.mcp.tools.common import (
24+
DocGroup,
25+
raise_validation_error,
26+
render_diff_table,
27+
resolve_project,
28+
)
2429
from testgen.mcp.tools.markdown import MdDoc
2530

2631
_DOC_GROUP = DocGroup.MANAGE
@@ -77,12 +82,7 @@ def update_project(
7782
if all(value is None for value in supplied.values()):
7883
raise MCPUserError("No fields supplied to update.")
7984

80-
perms = get_project_permissions()
81-
perms.verify_access(project_code, not_found=MCPResourceNotAccessible("Project", project_code))
82-
83-
project = Project.get(project_code)
84-
if project is None:
85-
raise MCPResourceNotAccessible("Project", project_code)
85+
project = resolve_project(project_code)
8686

8787
schedule = JobSchedule.get(
8888
JobSchedule.project_code == project_code,
@@ -164,12 +164,15 @@ def update_project(
164164
if effective_enabled
165165
else None,
166166
)
167-
changed = {attr for attr in before if before[attr] != after[attr]}
168167

169168
doc = MdDoc()
170169
doc.heading(1, f"Project `{project_code}` updated")
171170

172-
if not changed:
171+
rendered = render_diff_table(
172+
doc, before, after,
173+
attrs=_DIFF_ORDER, labels=_DIFF_LABELS, secret_attrs=_SECRET_ATTRS,
174+
)
175+
if not rendered:
173176
doc.text("No fields changed — supplied values matched the current state.")
174177
return doc.render()
175178

@@ -196,12 +199,6 @@ def update_project(
196199
project_code=project_code,
197200
)
198201

199-
rows: list[list[object]] = []
200-
for attr in _DIFF_ORDER:
201-
if attr not in changed:
202-
continue
203-
rows.append([_DIFF_LABELS[attr], _render_field_value(attr, before[attr]), _render_field_value(attr, after[attr])])
204-
doc.table(["Field", "Before", "After"], rows, code=[0])
205202
return doc.render()
206203

207204

@@ -249,14 +246,4 @@ def _snapshot(project: Project, schedule: Any) -> dict[str, Any]:
249246
"retention_cron_tz": "Retention timezone",
250247
}
251248

252-
253-
def _render_field_value(attr: str, value: Any) -> str | None:
254-
# Redact the API key — mcp-patterns "Secrets in inputs" rule. The diff still tells the LLM
255-
# the field changed; it just never echoes the value (cleartext OR masked).
256-
if attr == "observability_api_key" and value:
257-
return "[secret]"
258-
if isinstance(value, bool):
259-
return "Yes" if value else "No"
260-
if value is None or value == "":
261-
return None
262-
return str(value)
249+
_SECRET_ATTRS: frozenset[str] = frozenset({"observability_api_key"})

testgen/mcp/tools/table_groups.py

Lines changed: 3 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
format_flavor_label,
2626
format_page_footer,
2727
format_page_info,
28+
render_diff_table,
2829
resolve_connection,
2930
resolve_table_group,
3031
validate_limit,
@@ -424,13 +425,13 @@ def update_table_group(
424425
_raise_validation_error(errors, "Update rejected. No changes saved.")
425426

426427
after = _snapshot(table_group)
427-
changed = {attr for attr in before if before[attr] != after[attr]}
428428

429429
doc = MdDoc()
430430
doc.heading(1, f"Table Group `{table_group.table_groups_name}` updated")
431431
doc.field("ID", str(table_group.id), code=True)
432432

433-
if not changed:
433+
rendered = render_diff_table(doc, before, after, attrs=_DIFF_ATTRS, labels=_DIFF_LABELS)
434+
if not rendered:
434435
doc.text("No fields changed — supplied values matched the current state.")
435436
return doc.render()
436437

@@ -440,13 +441,6 @@ def update_table_group(
440441
_maybe_raise_duplicate_name(err)
441442
raise
442443

443-
rows: list[list[object]] = []
444-
for attr in _DIFF_ATTRS:
445-
if attr not in changed:
446-
continue
447-
label = _DIFF_LABELS.get(attr, attr)
448-
rows.append([label, _render_field_value(before[attr]), _render_field_value(after[attr])])
449-
doc.table(["Field", "Before", "After"], rows, code=[0])
450444
return doc.render()
451445

452446

@@ -722,14 +716,6 @@ def _snapshot(table_group: TableGroup) -> dict[str, Any]:
722716
return {attr: getattr(table_group, attr, None) for attr in _DIFF_ATTRS}
723717

724718

725-
def _render_field_value(value: Any) -> str | None:
726-
if isinstance(value, bool):
727-
return "Yes" if value else "No"
728-
if value is None or value == "":
729-
return None
730-
return str(value)
731-
732-
733719
def _render_created_table_group(table_group: TableGroup, connection: Connection) -> str:
734720
doc = MdDoc()
735721
doc.heading(1, f"Table Group `{table_group.table_groups_name}` created")

testgen/mcp/tools/test_suites.py

Lines changed: 3 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
DocGroup,
2020
parse_severity,
2121
raise_validation_error,
22+
render_diff_table,
2223
resolve_table_group,
2324
resolve_test_suite,
2425
)
@@ -193,24 +194,17 @@ def update_test_suite(
193194
for attr, value in updates.items():
194195
setattr(suite, attr, value)
195196
after = {attr: getattr(suite, attr, None) for attr in updates}
196-
changed = {attr for attr in before if before[attr] != after[attr]}
197197

198198
doc = MdDoc()
199199
doc.heading(1, f"Test Suite `{suite.test_suite}` updated")
200200
doc.field("ID", str(suite.id), code=True)
201201

202-
if not changed:
202+
rendered = render_diff_table(doc, before, after, attrs=_DIFF_ORDER, labels=_DIFF_LABELS)
203+
if not rendered:
203204
doc.text("No fields changed — supplied values matched the current state.")
204205
return doc.render()
205206

206207
suite.save()
207-
208-
rows: list[list[object]] = []
209-
for attr in _DIFF_ORDER:
210-
if attr not in changed:
211-
continue
212-
rows.append([_DIFF_LABELS[attr], _render_field_value(before[attr]), _render_field_value(after[attr])])
213-
doc.table(["Field", "Before", "After"], rows, code=[0])
214208
return doc.render()
215209

216210

@@ -270,11 +264,3 @@ def _render_created_suite(suite: TestSuite, *, table_group_name: str) -> str:
270264
"component_name": "Component name",
271265
"dq_score_exclude": "Exclude from quality scoring",
272266
}
273-
274-
275-
def _render_field_value(value: Any) -> str | None:
276-
if isinstance(value, bool):
277-
return "Yes" if value else "No"
278-
if value is None or value == "":
279-
return None
280-
return str(value)

0 commit comments

Comments
 (0)