Skip to content

Commit fbd021f

Browse files
diogodkclaude
authored andcommitted
feat(mcp): add CRUD write tools for projects and test suites (TG-1070)
Three new MCP write tools in core and one in the enterprise project-management plugin, so an LLM can manage projects and test suites without falling back to the UI: - update_project (core, administer permission): updates the full per-project settings surface exposed in the UI — display name, weighted DQ scoring toggle, DataOps Observability URL + key, data retention enable/days, and retention schedule cron + timezone. Side effects mirror the UI: weights flip triggers a background score recalc, retention toggles upsert/delete the scheduled cleanup job. observability_api_key is consumed but never echoed back (mcp-patterns secrets rule). - create_test_suite (core, edit permission): under a table group. Accepts the full UI-editable surface in one call — name, description, default severity, observability export toggle, DataOps Observability component fields, and scoring exclusion — so the LLM doesn't need a follow-up update_test_suite to finish configuring a new suite. Always non-monitor. - update_test_suite (core, edit permission): same surface, partial updates. Resolves through the existing monitor-filtered resolve_test_suite so a monitor-suite ID surfaces the unified "not found or not accessible" wording. Empty-string args on NullIfEmptyString columns normalize to None on the way in so the diff never reports a phantom change. create_project (enterprise plugin, global_admin) registers through the TG-1124 plugin MCP-tools hook in testgen_project_management. Mirrors the UI's "Add Project" handler: persists the row and schedules the data-retention cleanup job at the project's defaults so MCP-created projects behave the same as UI-created ones. Project creation is enterprise-only because it belongs to the project-management feature. global_admin is a User flag, not a per-project role, so the core mcp_permission decorator was extended to special-case it. Every other call site stays unchanged. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent b2eb075 commit fbd021f

9 files changed

Lines changed: 1559 additions & 6 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: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@
3838
from testgen.common.models.scheduler import SCHEDULABLE_JOB_KEYS, JobSchedule
3939
from testgen.common.models.scores import ScoreCategory, ScoreDefinition
4040
from testgen.common.models.table_group import TableGroup
41-
from testgen.common.models.test_definition import TestDefinition, TestDefinitionNote, TestType
41+
from testgen.common.models.test_definition import Severity, TestDefinition, TestDefinitionNote, TestType
4242
from testgen.common.models.test_result import TestResult, TestResultStatus
4343
from testgen.common.models.test_suite import TestSuite
4444
from testgen.mcp.exceptions import MCPResourceNotAccessible, MCPUserError
@@ -132,6 +132,15 @@ def parse_quality_dimension(value: str) -> QualityDimension:
132132
raise MCPUserError(f"Invalid quality_dimension `{value}`. Valid values: {valid}") from err
133133

134134

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

testgen/mcp/tools/projects.py

Lines changed: 262 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,262 @@
1+
"""MCP write tools for projects.
2+
3+
Project creation and deletion live in the enterprise project-management plugin
4+
(gated on ``global_admin``); editing a project's own settings is a per-project
5+
``administer`` operation that ships in core. This module holds the latter.
6+
"""
7+
8+
from __future__ import annotations
9+
10+
from typing import Any
11+
12+
from testgen.common.enums import JobKey, JobSource
13+
from testgen.common.models import with_database_session
14+
from testgen.common.models.job_execution import JobExecution
15+
from testgen.common.models.project import Project
16+
from testgen.common.models.scheduler import (
17+
DEFAULT_DATA_CLEANUP_CRON,
18+
DEFAULT_RETENTION_CRON_TZ,
19+
JobSchedule,
20+
)
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
24+
from testgen.mcp.tools.markdown import MdDoc
25+
26+
_DOC_GROUP = DocGroup.MANAGE
27+
28+
DEFAULT_RETENTION_DAYS = 180
29+
30+
31+
def _empty_to_none(value: str | None) -> str | None:
32+
return value if value else None
33+
34+
35+
@with_database_session
36+
@mcp_permission("administer")
37+
def update_project(
38+
project_code: str,
39+
*,
40+
project_name: str | None = None,
41+
use_dq_score_weights: bool | None = None,
42+
observability_api_url: str | None = None,
43+
observability_api_key: str | None = None,
44+
data_retention_enabled: bool | None = None,
45+
data_retention_days: int | None = None,
46+
retention_cron_expr: str | None = None,
47+
retention_cron_tz: str | None = None,
48+
) -> str:
49+
"""Update a project's settings.
50+
51+
Args:
52+
project_code: The project code, e.g. from `list_projects`.
53+
project_name: New display name.
54+
use_dq_score_weights: Whether quality scoring uses the configured weights.
55+
Changing this re-runs project-wide score recalculation in the background.
56+
observability_api_url: DataOps Observability API URL. Pass an empty string to clear.
57+
observability_api_key: DataOps Observability API key. Pass an empty string to clear.
58+
Never echoed back in tool output.
59+
data_retention_enabled: Whether old profiling and test history is automatically deleted.
60+
data_retention_days: How many days of history to keep (only when retention is enabled).
61+
Defaults to 180 when retention is being enabled without specifying days.
62+
retention_cron_expr: Cron expression for the retention cleanup job
63+
(only when retention is enabled). Defaults to daily at 01:00.
64+
retention_cron_tz: Timezone for the retention cleanup cron
65+
(only when retention is enabled). Defaults to UTC.
66+
"""
67+
supplied = {
68+
"project_name": project_name,
69+
"use_dq_score_weights": use_dq_score_weights,
70+
"observability_api_url": observability_api_url,
71+
"observability_api_key": observability_api_key,
72+
"data_retention_enabled": data_retention_enabled,
73+
"data_retention_days": data_retention_days,
74+
"retention_cron_expr": retention_cron_expr,
75+
"retention_cron_tz": retention_cron_tz,
76+
}
77+
if all(value is None for value in supplied.values()):
78+
raise MCPUserError("No fields supplied to update.")
79+
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)
86+
87+
schedule = JobSchedule.get(
88+
JobSchedule.project_code == project_code,
89+
JobSchedule.key == JobKey.run_data_cleanup,
90+
)
91+
92+
# Compute effective retention state from supplied args + current state. Errors below.
93+
effective_enabled = (
94+
data_retention_enabled if data_retention_enabled is not None else project.data_retention_enabled
95+
)
96+
effective_days = (
97+
data_retention_days if data_retention_days is not None else project.data_retention_days
98+
)
99+
# When retention is being newly enabled without explicit days, fall back to the system default.
100+
if effective_enabled and effective_days is None:
101+
effective_days = DEFAULT_RETENTION_DAYS
102+
103+
# Validate field-by-field; surface all errors at once.
104+
errors: list[str] = []
105+
cleaned_name: str | None = None
106+
if project_name is not None:
107+
cleaned_name = project_name.strip()
108+
if not cleaned_name:
109+
errors.append("project_name: must not be empty.")
110+
111+
if data_retention_days is not None and data_retention_days < 1:
112+
errors.append("data_retention_days: must be a positive integer.")
113+
114+
# Setting retention schedule args only makes sense when retention will be enabled.
115+
if not effective_enabled:
116+
if data_retention_days is not None:
117+
errors.append("data_retention_days: cannot be set when data_retention_enabled is False.")
118+
if retention_cron_expr is not None:
119+
errors.append("retention_cron_expr: cannot be set when data_retention_enabled is False.")
120+
if retention_cron_tz is not None:
121+
errors.append("retention_cron_tz: cannot be set when data_retention_enabled is False.")
122+
123+
if errors:
124+
raise_validation_error(errors, "Update rejected. No changes saved.")
125+
126+
weights_were = project.use_dq_score_weights
127+
128+
# Snapshot the editable surface (including schedule cron + tz, which live on JobSchedule).
129+
before = _snapshot(project, schedule)
130+
131+
# Apply project changes.
132+
if cleaned_name is not None:
133+
project.project_name = cleaned_name
134+
if use_dq_score_weights is not None:
135+
project.use_dq_score_weights = use_dq_score_weights
136+
if observability_api_url is not None:
137+
project.observability_api_url = _empty_to_none(observability_api_url)
138+
if observability_api_key is not None:
139+
project.observability_api_key = _empty_to_none(observability_api_key)
140+
project.data_retention_enabled = effective_enabled
141+
project.data_retention_days = effective_days if effective_enabled else None
142+
143+
# Compute the effective cron values now so the snapshot reflects what the schedule will be.
144+
schedule_supplied = any(
145+
v is not None
146+
for v in (data_retention_enabled, data_retention_days, retention_cron_expr, retention_cron_tz)
147+
)
148+
effective_cron_expr: str | None
149+
effective_cron_tz: str | None
150+
if effective_enabled:
151+
effective_cron_expr = (
152+
retention_cron_expr or (schedule.cron_expr if schedule else None) or DEFAULT_DATA_CLEANUP_CRON
153+
)
154+
effective_cron_tz = (
155+
retention_cron_tz or (schedule.cron_tz if schedule else None) or DEFAULT_RETENTION_CRON_TZ
156+
)
157+
else:
158+
effective_cron_expr = None
159+
effective_cron_tz = None
160+
161+
after = _snapshot(
162+
project,
163+
_ScheduleSnapshot(cron_expr=effective_cron_expr, cron_tz=effective_cron_tz)
164+
if effective_enabled
165+
else None,
166+
)
167+
changed = {attr for attr in before if before[attr] != after[attr]}
168+
169+
doc = MdDoc()
170+
doc.heading(1, f"Project `{project_code}` updated")
171+
172+
if not changed:
173+
doc.text("No fields changed — supplied values matched the current state.")
174+
return doc.render()
175+
176+
project.save()
177+
178+
# Schedule side effects: only touch JobSchedule when a retention-related arg was supplied.
179+
if schedule_supplied:
180+
if effective_enabled:
181+
JobSchedule.upsert_for_retention(
182+
project_code=project_code,
183+
retention_days=effective_days, # type: ignore[arg-type]
184+
cron_expr=effective_cron_expr, # type: ignore[arg-type]
185+
cron_tz=effective_cron_tz, # type: ignore[arg-type]
186+
)
187+
else:
188+
JobSchedule.delete_for_retention(project_code)
189+
190+
# Weights side effect: submit a background recalculation job, same as the UI.
191+
if use_dq_score_weights is not None and use_dq_score_weights != weights_were:
192+
JobExecution.submit(
193+
job_key=JobKey.recalculate_project_scores,
194+
kwargs={"project_code": project_code},
195+
source=JobSource.mcp,
196+
project_code=project_code,
197+
)
198+
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])
205+
return doc.render()
206+
207+
208+
class _ScheduleSnapshot:
209+
"""Stand-in for a JobSchedule row, used while computing the post-update snapshot
210+
before any DB write has happened."""
211+
212+
def __init__(self, cron_expr: str | None, cron_tz: str | None) -> None:
213+
self.cron_expr = cron_expr
214+
self.cron_tz = cron_tz
215+
216+
217+
def _snapshot(project: Project, schedule: Any) -> dict[str, Any]:
218+
return {
219+
"project_name": project.project_name,
220+
"use_dq_score_weights": project.use_dq_score_weights,
221+
"observability_api_url": project.observability_api_url,
222+
"observability_api_key": project.observability_api_key,
223+
"data_retention_enabled": project.data_retention_enabled,
224+
"data_retention_days": project.data_retention_days,
225+
"retention_cron_expr": schedule.cron_expr if schedule is not None else None,
226+
"retention_cron_tz": schedule.cron_tz if schedule is not None else None,
227+
}
228+
229+
230+
_DIFF_ORDER: tuple[str, ...] = (
231+
"project_name",
232+
"use_dq_score_weights",
233+
"observability_api_url",
234+
"observability_api_key",
235+
"data_retention_enabled",
236+
"data_retention_days",
237+
"retention_cron_expr",
238+
"retention_cron_tz",
239+
)
240+
241+
_DIFF_LABELS: dict[str, str] = {
242+
"project_name": "Name",
243+
"use_dq_score_weights": "Weighted quality scoring",
244+
"observability_api_url": "DataOps Observability API URL",
245+
"observability_api_key": "DataOps Observability API key",
246+
"data_retention_enabled": "Data retention",
247+
"data_retention_days": "Retention days",
248+
"retention_cron_expr": "Retention cron expression",
249+
"retention_cron_tz": "Retention timezone",
250+
}
251+
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)

0 commit comments

Comments
 (0)