Skip to content

Commit 3216d15

Browse files
diogodkclaude
andcommitted
feat(mcp): add entity read tools for projects, connections, table groups, test suites (TG-1053)
Adds six read tools so an MCP client can introspect TestGen configuration before invoking write tools: get_project, list_connections, get_connection, list_table_groups, get_table_group, get_test_suite. All gate on the view permission; get_connection redacts every encrypted field; get_test_suite rejects monitor suites with the unified missing-or-inaccessible wording; sql_flavor_code is rendered as its display label everywhere. Backing model helpers: TestSuite.test_definition_stats aggregates total / locked counts plus per-test-type breakdown in one query; Connection .list_for_project and TableGroup.list_for_project / list_for_connection return paginated config-focused list rows with relevant activity timestamps. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 43ec6f0 commit 3216d15

10 files changed

Lines changed: 1101 additions & 7 deletions

File tree

testgen/common/models/connection.py

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,17 @@ class ConnectionMinimal(EntityMinimal):
4343
connection_name: str
4444

4545

46+
@dataclass
47+
class ConnectionListItem(EntityMinimal):
48+
connection_id: int
49+
connection_name: str
50+
project_code: str
51+
sql_flavor_code: SQLFlavorCode
52+
project_host: str | None
53+
project_db: str | None
54+
table_group_count: int
55+
56+
4657
class Connection(Entity):
4758
__tablename__ = "connections"
4859

@@ -93,6 +104,35 @@ def select_minimal_where(
93104
results = cls._select_columns_where(cls._minimal_columns, *clauses, order_by=order_by)
94105
return [ConnectionMinimal(**row) for row in results]
95106

107+
@classmethod
108+
def list_for_project(
109+
cls, project_code: str, *clauses, page: int = 1, limit: int = 20
110+
) -> tuple[list[ConnectionListItem], int]:
111+
"""Paginated listing of connections in a project, with their table-group counts."""
112+
tg_count_subq = (
113+
select(
114+
TableGroup.connection_id.label("connection_id"),
115+
func.count().label("table_group_count"),
116+
)
117+
.group_by(TableGroup.connection_id)
118+
.subquery()
119+
)
120+
query = (
121+
select(
122+
cls.connection_id,
123+
cls.connection_name,
124+
cls.project_code,
125+
cls.sql_flavor_code,
126+
cls.project_host,
127+
cls.project_db,
128+
func.coalesce(tg_count_subq.c.table_group_count, 0).label("table_group_count"),
129+
)
130+
.outerjoin(tg_count_subq, tg_count_subq.c.connection_id == cls.connection_id)
131+
.where(cls.project_code == project_code, *clauses)
132+
.order_by(*cls._default_order_by)
133+
)
134+
return cls._paginate(query, page=page, limit=limit, data_class=ConnectionListItem)
135+
96136
@classmethod
97137
def is_in_use(cls, ids: list[str]) -> bool:
98138
table_groups = TableGroup.select_minimal_where(TableGroup.connection_id.in_(ids))

testgen/common/models/table_group.py

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,19 @@ class TableGroupStats(EntityMinimal):
4747
data_point_ct: int
4848

4949

50+
@dataclass
51+
class TableGroupListItem(EntityMinimal):
52+
id: UUID
53+
table_groups_name: str
54+
table_group_schema: str
55+
project_code: str
56+
connection_name: str | None
57+
table_count: int
58+
last_profiled_date: datetime | None
59+
last_tested_date: datetime | None
60+
testing_score: float | None
61+
62+
5063
@dataclass
5164
class TableGroupSummary(EntityMinimal):
5265
id: UUID
@@ -385,6 +398,89 @@ def select_summary(
385398
total = items[0].total_count if items else 0
386399
return items, total
387400

401+
@classmethod
402+
def list_for_project(
403+
cls, project_code: str, *, page: int = 1, limit: int = 20
404+
) -> tuple[list[TableGroupListItem], int]:
405+
"""Config-focused paginated listing for a project, with table count and activity timestamps."""
406+
return cls._list_with_activity(
407+
scope_sql="groups.project_code = :project_code",
408+
scope_params={"project_code": project_code},
409+
page=page,
410+
limit=limit,
411+
)
412+
413+
@classmethod
414+
def list_for_connection(
415+
cls, connection_id: int, *, page: int = 1, limit: int = 20
416+
) -> tuple[list[TableGroupListItem], int]:
417+
"""Config-focused paginated listing for a connection, with table count and activity timestamps."""
418+
return cls._list_with_activity(
419+
scope_sql="groups.connection_id = :connection_id",
420+
scope_params={"connection_id": connection_id},
421+
page=page,
422+
limit=limit,
423+
)
424+
425+
@classmethod
426+
def _list_with_activity(
427+
cls,
428+
*,
429+
scope_sql: str,
430+
scope_params: dict,
431+
page: int,
432+
limit: int,
433+
) -> tuple[list[TableGroupListItem], int]:
434+
params: dict = {**scope_params, "limit": limit, "offset": (page - 1) * limit}
435+
query = f"""
436+
WITH stats AS (
437+
SELECT table_groups_id, COUNT(*) AS table_count
438+
FROM data_table_chars
439+
GROUP BY table_groups_id
440+
),
441+
latest_profile AS (
442+
SELECT pr.table_groups_id, MAX(je.started_at) AS started_at
443+
FROM profiling_runs pr
444+
LEFT JOIN job_executions je ON je.id = pr.job_execution_id
445+
GROUP BY pr.table_groups_id
446+
),
447+
latest_test AS (
448+
SELECT ts.table_groups_id, MAX(tr.test_starttime) AS test_starttime
449+
FROM test_runs tr
450+
JOIN test_suites ts ON ts.id = tr.test_suite_id
451+
WHERE ts.is_monitor IS NOT TRUE
452+
GROUP BY ts.table_groups_id
453+
)
454+
SELECT
455+
groups.id,
456+
groups.table_groups_name,
457+
groups.table_group_schema,
458+
groups.project_code,
459+
connections.connection_name,
460+
COALESCE(stats.table_count, 0) AS table_count,
461+
latest_profile.started_at AS last_profiled_date,
462+
latest_test.test_starttime AS last_tested_date,
463+
groups.dq_score_testing AS testing_score,
464+
COUNT(*) OVER() AS total_count
465+
FROM table_groups AS groups
466+
LEFT JOIN connections ON connections.connection_id = groups.connection_id
467+
LEFT JOIN stats ON stats.table_groups_id = groups.id
468+
LEFT JOIN latest_profile ON latest_profile.table_groups_id = groups.id
469+
LEFT JOIN latest_test ON latest_test.table_groups_id = groups.id
470+
WHERE {scope_sql}
471+
ORDER BY LOWER(groups.table_groups_name)
472+
LIMIT :limit OFFSET :offset;
473+
"""
474+
rows = get_current_session().execute(text(query), params).mappings().all()
475+
if not rows:
476+
return [], 0
477+
total = int(rows[0]["total_count"])
478+
items = [
479+
TableGroupListItem(**{k: v for k, v in row.items() if k != "total_count"})
480+
for row in rows
481+
]
482+
return items, total
483+
388484
@classmethod
389485
def is_in_use(cls, ids: list[str]) -> bool:
390486
test_suites = TestSuite.select_minimal_where(TestSuite.table_groups_id.in_(ids))

testgen/common/models/test_suite.py

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,15 @@ class TestSuiteMinimal(EntityMinimal):
2929
export_to_observability: str
3030

3131

32+
@dataclass(frozen=True)
33+
class TestDefinitionStats:
34+
"""Aggregate test-definition counts for a test suite."""
35+
36+
total: int
37+
locked: int
38+
counts_by_type: dict[str, int]
39+
40+
3241
@dataclass
3342
class TestSuiteSummary(EntityMinimal):
3443
id: UUID
@@ -213,6 +222,30 @@ def select_summary(cls, project_code: str, table_group_id: str | UUID | None = N
213222
results = db_session.execute(text(query), params).mappings().all()
214223
return [TestSuiteSummary(**row) for row in results]
215224

225+
@classmethod
226+
def test_definition_stats(cls, test_suite_id: str | UUID) -> "TestDefinitionStats":
227+
"""Aggregate test-definition counts for a suite: total, locked, and per-test-type."""
228+
query = """
229+
SELECT
230+
test_type,
231+
COUNT(*) AS total,
232+
SUM(CASE WHEN COALESCE(lock_refresh, 'N') = 'Y' THEN 1 ELSE 0 END) AS locked
233+
FROM test_definitions
234+
WHERE test_suite_id = :test_suite_id
235+
GROUP BY test_type
236+
ORDER BY test_type;
237+
"""
238+
rows = (
239+
get_current_session()
240+
.execute(text(query), {"test_suite_id": str(test_suite_id)})
241+
.mappings()
242+
.all()
243+
)
244+
counts_by_type = {row["test_type"]: int(row["total"]) for row in rows}
245+
total = sum(counts_by_type.values())
246+
locked = sum(int(row["locked"]) for row in rows)
247+
return TestDefinitionStats(total=total, locked=locked, counts_by_type=counts_by_type)
248+
216249
@classmethod
217250
def is_in_use(cls, ids: list[str]) -> bool:
218251
query = """
@@ -248,4 +281,3 @@ def cascade_delete(cls, ids: list[str]) -> None:
248281
db_session = get_current_session()
249282
db_session.execute(text(query), {"test_suite_ids": tuple(ids)})
250283
cls.delete_where(cls.id.in_(ids))
251-

testgen/mcp/server.py

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -140,9 +140,20 @@ def build_mcp_server(
140140
profiling_overview,
141141
table_health,
142142
)
143-
from testgen.mcp.tools.connections import test_connection
143+
from testgen.mcp.tools.connections import (
144+
get_connection,
145+
list_connections,
146+
test_connection,
147+
)
144148
from testgen.mcp.tools.data_catalog import update_catalog_metadata
145-
from testgen.mcp.tools.discovery import get_data_inventory, list_projects, list_tables, list_test_suites
149+
from testgen.mcp.tools.discovery import (
150+
get_data_inventory,
151+
get_project,
152+
get_test_suite,
153+
list_projects,
154+
list_tables,
155+
list_test_suites,
156+
)
146157
from testgen.mcp.tools.execution import (
147158
cancel_profiling_run,
148159
cancel_test_run,
@@ -208,6 +219,8 @@ def build_mcp_server(
208219
from testgen.mcp.tools.source_data import get_source_data, get_source_data_query, get_table_sample
209220
from testgen.mcp.tools.table_groups import (
210221
create_table_group,
222+
get_table_group,
223+
list_table_groups,
211224
preview_table_group,
212225
update_table_group,
213226
)
@@ -263,8 +276,10 @@ def safe_prompt(fn):
263276
# Tools
264277
safe_tool(get_data_inventory)
265278
safe_tool(list_projects)
279+
safe_tool(get_project)
266280
safe_tool(list_tables)
267281
safe_tool(list_test_suites)
282+
safe_tool(get_test_suite)
268283
safe_tool(list_test_runs)
269284
safe_tool(get_test_run)
270285
safe_tool(list_test_results)
@@ -329,7 +344,11 @@ def safe_prompt(fn):
329344
safe_tool(create_notification)
330345
safe_tool(update_notification)
331346
safe_tool(delete_notification)
347+
safe_tool(list_connections)
348+
safe_tool(get_connection)
332349
safe_tool(test_connection)
350+
safe_tool(list_table_groups)
351+
safe_tool(get_table_group)
333352
safe_tool(create_table_group)
334353
safe_tool(update_table_group)
335354
safe_tool(preview_table_group)

testgen/mcp/tools/connections.py

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,14 +26,115 @@
2626
apply_connection_params,
2727
connection_display_fields,
2828
connection_field_labels,
29+
format_page_footer,
30+
format_page_info,
2931
infer_mode,
3032
parse_sql_flavor,
3133
resolve_connection,
3234
validate_connection_fields,
35+
validate_limit,
36+
validate_page,
3337
)
3438
from testgen.mcp.tools.markdown import MdDoc
3539

3640

41+
@with_database_session
42+
@mcp_permission("view")
43+
def list_connections(project_code: str, page: int = 1, limit: int = 20) -> str:
44+
"""List database connections in a project with their database type and table-group counts.
45+
46+
Use this before changing or referencing a connection to confirm its ID, name, and host.
47+
Credentials are never returned.
48+
49+
Args:
50+
project_code: The project code to list connections for.
51+
page: Page number starting at 1 (default 1).
52+
limit: Page size (default 20, max 100).
53+
"""
54+
validate_page(page)
55+
validate_limit(limit, 100)
56+
57+
perms = get_project_permissions()
58+
perms.verify_access(project_code, not_found=MCPResourceNotAccessible("Project", project_code))
59+
60+
rows, total = Connection.list_for_project(project_code, page=page, limit=limit)
61+
62+
if not rows:
63+
if page > 1:
64+
return f"No connections on page {page} (total: {total})."
65+
return f"No connections found for project `{project_code}`."
66+
67+
doc = MdDoc()
68+
doc.heading(1, f"Connections for `{project_code}`")
69+
doc.text(format_page_info(total, page, limit))
70+
table_rows: list[list[object]] = []
71+
for row in rows:
72+
label = SQL_FLAVOR_CODE_TO_LABEL.get(row.sql_flavor_code)
73+
database_type = label.value if label else row.sql_flavor_code
74+
table_rows.append(
75+
[
76+
row.connection_id,
77+
row.connection_name,
78+
database_type,
79+
row.project_host,
80+
row.project_db,
81+
row.table_group_count,
82+
]
83+
)
84+
doc.table(
85+
["ID", "Name", "Type", "Host", "Database", "Table groups"],
86+
table_rows,
87+
code=[0, 3, 4],
88+
)
89+
if footer := format_page_footer(total, page, limit):
90+
doc.text(footer)
91+
return doc.render()
92+
93+
94+
@with_database_session
95+
@mcp_permission("view")
96+
def get_connection(connection_id: int) -> str:
97+
"""Get a connection's configuration, including database type, host, and authentication mode.
98+
99+
Credentials (password, private key, service-account key) are never returned.
100+
Use this before editing a connection or creating a table group on it.
101+
102+
Args:
103+
connection_id: Bigint connection ID returned by `list_connections` or shown on the connections page.
104+
"""
105+
connection = resolve_connection(connection_id)
106+
107+
doc = MdDoc()
108+
doc.heading(1, f"Connection `{connection.connection_name}`")
109+
doc.field("ID", connection.connection_id, code=True)
110+
doc.field("Project", connection.project_code, code=True)
111+
label = SQL_FLAVOR_CODE_TO_LABEL.get(connection.sql_flavor_code)
112+
doc.field("Type", label.value if label else connection.sql_flavor_code)
113+
114+
if connection.project_host:
115+
doc.field("Host", connection.project_host, code=True)
116+
if connection.project_port:
117+
doc.field("Port", connection.project_port)
118+
if connection.project_db:
119+
doc.field("Database", connection.project_db, code=True)
120+
if connection.project_user:
121+
doc.field("User", connection.project_user, code=True)
122+
if connection.connect_by_url and connection.url:
123+
doc.field("URL", connection.url, code=True)
124+
if connection.warehouse:
125+
doc.field("Warehouse", connection.warehouse, code=True)
126+
if connection.http_path:
127+
doc.field("HTTP Path", connection.http_path, code=True)
128+
doc.field("Authentication", _authentication_label(connection))
129+
130+
if connection.max_threads is not None:
131+
doc.field("Max Threads", connection.max_threads)
132+
if connection.max_query_chars is not None:
133+
doc.field("Max Expression Length", connection.max_query_chars)
134+
135+
return doc.render()
136+
137+
37138
@with_database_session
38139
@mcp_permission("administer")
39140
def test_connection(

0 commit comments

Comments
 (0)