Skip to content

Commit 8e3d36e

Browse files
author
ci bot
committed
Merge branch 'feat/TG-1070-mcp-projects-suites-crud' into 'enterprise'
feat(mcp): add CRUD write tools for projects and test suites (TG-1070) See merge request dkinternal/testgen/dataops-testgen!570
2 parents b2eb075 + e5ec5c4 commit 8e3d36e

10 files changed

Lines changed: 1804 additions & 23 deletions

File tree

testgen/mcp/permissions.py

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -138,17 +138,29 @@ def mcp_permission(permission: str) -> Callable:
138138
Raises ``MCPPermissionDenied`` if the user has no projects with the required
139139
permission. Other ``MCPPermissionDenied`` exceptions from tool code propagate
140140
through — the ``safe_tool`` error boundary handles conversion to text.
141+
142+
``global_admin`` is a user-level flag, not a per-project role: the check
143+
consults ``User.is_global_admin`` and the resulting ``ProjectPermissions``
144+
has empty ``memberships``. Tools gated on ``global_admin`` operate above
145+
project scope and should not call ``get_project_permissions()``.
141146
"""
142147

143148
def decorator(fn: Callable) -> Callable:
144149
@functools.wraps(fn)
145150
def wrapper(*args, **kwargs):
146151
user = get_authorized_mcp_user()
147-
perms = _compute_project_permissions(user, permission)
148-
if not perms.allowed_codes:
149-
raise MCPPermissionDenied(
150-
"Your role does not include the necessary permission for this operation on any project."
151-
)
152+
if permission == "global_admin":
153+
if not user.is_global_admin:
154+
raise MCPPermissionDenied(
155+
"Your role does not include the necessary permission for this operation."
156+
)
157+
perms = ProjectPermissions(memberships={}, permission=permission, username=user.username)
158+
else:
159+
perms = _compute_project_permissions(user, permission)
160+
if not perms.allowed_codes:
161+
raise MCPPermissionDenied(
162+
"Your role does not include the necessary permission for this operation on any project."
163+
)
152164
tok = _mcp_project_permissions.set(perms)
153165
try:
154166
return fn(*args, **kwargs)

testgen/mcp/server.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -204,6 +204,7 @@ def build_mcp_server(
204204
list_profiling_summaries,
205205
search_columns,
206206
)
207+
from testgen.mcp.tools.projects import update_project
207208
from testgen.mcp.tools.quality_scores import (
208209
create_scorecard,
209210
delete_scorecard,
@@ -263,6 +264,7 @@ def build_mcp_server(
263264
update_test_result,
264265
)
265266
from testgen.mcp.tools.test_runs import get_test_run, list_test_runs
267+
from testgen.mcp.tools.test_suites import create_test_suite, update_test_suite
266268

267269
if server_url is None:
268270
server_url = f"{api_base_url}/mcp"
@@ -375,6 +377,9 @@ def safe_prompt(fn):
375377
safe_tool(create_table_group)
376378
safe_tool(update_table_group)
377379
safe_tool(preview_table_group)
380+
safe_tool(update_project)
381+
safe_tool(create_test_suite)
382+
safe_tool(update_test_suite)
378383
safe_tool(update_catalog_metadata)
379384

380385
# Resources

testgen/mcp/tools/common.py

Lines changed: 91 additions & 1 deletion
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,10 +36,11 @@
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
41-
from testgen.common.models.test_definition import TestDefinition, TestDefinitionNote, TestType
43+
from testgen.common.models.test_definition import Severity, TestDefinition, TestDefinitionNote, TestType
4244
from testgen.common.models.test_result import TestResult, TestResultStatus
4345
from testgen.common.models.test_suite import TestSuite
4446
from testgen.mcp.exceptions import MCPResourceNotAccessible, MCPUserError
@@ -132,6 +134,15 @@ def parse_quality_dimension(value: str) -> QualityDimension:
132134
raise MCPUserError(f"Invalid quality_dimension `{value}`. Valid values: {valid}") from err
133135

134136

137+
def parse_severity(value: str) -> Severity:
138+
"""Validate a test-suite default severity. Accepts ``Fail`` or ``Warning``."""
139+
try:
140+
return Severity(value)
141+
except ValueError as err:
142+
valid = ", ".join(s.value for s in Severity)
143+
raise MCPUserError(f"Invalid severity `{value}`. Valid values: {valid}") from err
144+
145+
135146
class ScoreGroupBy(StrEnum):
136147
"""User-facing values accepted for the ``group_by`` argument on quality-score rollups."""
137148

@@ -619,10 +630,89 @@ def format_page_footer(total: int, page: int, limit: int) -> str:
619630
return f"_Page {page} of {total_pages}. Use `page={page + 1}` for more._"
620631

621632

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+
622698
# Entity resolution helpers — see mcp-roadmap.md "Entity Resolution Helpers" guideline.
623699
# Extract a new resolve_<entity> here when a second caller needs the same parse-uuid +
624700
# perm-scoped lookup + collapsed-error pattern.
625701

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+
626716
def resolve_connection(connection_id: int) -> Connection:
627717
"""Resolve a connection ID, collapsing missing-or-inaccessible into one error path."""
628718
perms = get_project_permissions()

0 commit comments

Comments
 (0)