Skip to content

Commit e9d1604

Browse files
author
ci bot
committed
Merge branch 'feat/TG-1124-mcp-enterprise-gating' into 'enterprise'
feat(mcp): gate connection write tools behind multi_connections plugin (TG-1124) See merge request dkinternal/testgen/dataops-testgen!565
2 parents 4dbea0e + 885053f commit e9d1604

10 files changed

Lines changed: 183 additions & 220 deletions

File tree

testgen/mcp/server.py

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -146,10 +146,10 @@ def build_mcp_server(
146146
table_health,
147147
)
148148
from testgen.mcp.tools.connections import (
149-
get_connection,
149+
get_connection,
150150
list_connections,
151151
test_connection,
152-
)
152+
)
153153
from testgen.mcp.tools.data_catalog import update_catalog_metadata
154154
from testgen.mcp.tools.discovery import (
155155
get_data_inventory,
@@ -378,6 +378,17 @@ def safe_prompt(fn):
378378
safe_prompt(profiling_overview)
379379
safe_prompt(hygiene_triage)
380380

381+
# Register plugin-provided tools through the same safe_tool wrapper as the core tools above.
382+
from testgen.utils.plugins import discover
383+
384+
for plugin in discover():
385+
try:
386+
spec = plugin.load()
387+
for tool in spec.get_mcp_tools():
388+
safe_tool(tool)
389+
except Exception:
390+
LOG.warning("Plugin %s failed to load; skipping its MCP tools", plugin.package)
391+
381392
return mcp
382393

383394

testgen/mcp/tools/common.py

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@
4242
from testgen.common.models.test_suite import TestSuite
4343
from testgen.mcp.exceptions import MCPResourceNotAccessible, MCPUserError
4444
from testgen.mcp.permissions import get_project_permissions
45+
from testgen.mcp.tools.markdown import MdDoc
4546

4647
# User-facing label for ``Disposition.INACTIVE`` is "Muted" — accept that label on input.
4748
_DISPOSITION_USER_TO_DB: dict[str, Disposition] = {
@@ -1374,3 +1375,54 @@ def validate_connection_fields(connection: Connection) -> list[str]:
13741375
errors.append(f"`max_query_chars` must be between {_MAX_QUERY_CHARS_MIN} and {_MAX_QUERY_CHARS_MAX}.")
13751376

13761377
return errors
1378+
1379+
1380+
def effective_mode(connection: Connection, connection_mode: str | None) -> str | None:
1381+
"""Mode label to apply: the explicit override, else the connection's current mode."""
1382+
if connection_mode is not None:
1383+
return connection_mode
1384+
inferred = infer_mode(connection)
1385+
return str(inferred) if inferred is not None else None
1386+
1387+
1388+
def raise_validation_error(errors: list[str], header: str) -> None:
1389+
bullets = "\n".join(f"- {err}" for err in errors)
1390+
raise MCPUserError(f"{header}\n\n{bullets}")
1391+
1392+
1393+
def render_connection_body(doc: MdDoc, connection: Connection) -> None:
1394+
"""Render every non-secret connection field below the heading.
1395+
1396+
Encrypted columns are filtered out via ``ConnField.secret``.
1397+
"""
1398+
doc.field("ID", connection.connection_id, code=True)
1399+
doc.field("Project", connection.project_code, code=True)
1400+
doc.field("Type", format_flavor_label(connection.sql_flavor_code))
1401+
1402+
# Each populated, non-secret field under its flavor-specific label
1403+
# (e.g. "Catalog" for Databricks, "Login URL" for Salesforce).
1404+
for fld in connection_display_fields(connection):
1405+
if fld.secret:
1406+
continue
1407+
value = getattr(connection, fld.column, None)
1408+
if value in (None, ""):
1409+
continue
1410+
doc.field(fld.label, value, code=fld.column != "project_port")
1411+
1412+
doc.field("Authentication", authentication_label(connection))
1413+
if connection.max_threads is not None:
1414+
doc.field("Max Threads", connection.max_threads)
1415+
if connection.max_query_chars is not None:
1416+
doc.field("Max Expression Length", connection.max_query_chars)
1417+
1418+
1419+
def authentication_label(connection: Connection) -> str:
1420+
"""The connection's auth method: the active connection mode for multi-mode
1421+
flavors, else the implicit method (service account key, else password).
1422+
"""
1423+
mode = infer_mode(connection)
1424+
if mode is not None:
1425+
return str(mode)
1426+
if connection.service_account_key:
1427+
return "Service Account Key"
1428+
return "Password"

testgen/mcp/tools/connections.py

Lines changed: 15 additions & 145 deletions
Original file line numberDiff line numberDiff line change
@@ -1,43 +1,42 @@
1-
"""MCP tools for database connectioncreate, update, and test database connections.
1+
"""MCP tools for database connectionslist, get, and test database connections.
22
3-
Each tool gates on the ``administer`` permission. The per-flavor connection shape
4-
(which auth modes exist and which ``connection_params`` keys each needs) lives in
5-
``testgen.common.database.connection_service`` and is exposed to the model through
6-
the ``testgen://connection-parameters/{flavor}`` resource. Validation and
7-
auth-path normalization are delegated to that same module so the rules stay in
8-
one place.
3+
The per-flavor connection shape (which auth modes exist and which ``connection_params`` keys
4+
each needs) lives in ``testgen.common.database.connection_service`` and is exposed to the
5+
model through the ``testgen://connection-parameters/{flavor}`` resource. Validation and
6+
auth-path normalization are delegated to that same module so the rules stay in one place.
97
"""
108

119
from __future__ import annotations
1210

13-
from typing import Any
14-
1511
from testgen.common.database.connection_service import (
1612
apply_connection_defaults,
1713
normalize_auth_fields,
1814
test_connection_status,
1915
)
20-
from testgen.common.models import get_current_session, with_database_session
16+
from testgen.common.models import with_database_session
2117
from testgen.common.models.connection import Connection
2218
from testgen.mcp.exceptions import MCPResourceNotAccessible, MCPUserError
2319
from testgen.mcp.permissions import get_project_permissions, mcp_permission
2420
from testgen.mcp.tools.common import (
2521
SQL_FLAVOR_CODE_TO_LABEL,
22+
DocGroup,
2623
apply_connection_params,
27-
connection_display_fields,
28-
connection_field_labels,
24+
effective_mode,
2925
format_flavor_label,
3026
format_page_footer,
3127
format_page_info,
32-
infer_mode,
3328
parse_sql_flavor,
29+
raise_validation_error,
30+
render_connection_body,
3431
resolve_connection,
3532
validate_connection_fields,
3633
validate_limit,
3734
validate_page,
3835
)
3936
from testgen.mcp.tools.markdown import MdDoc
4037

38+
_DOC_GROUP = DocGroup.MANAGE
39+
4140

4241
@with_database_session
4342
@mcp_permission("view")
@@ -104,7 +103,7 @@ def get_connection(connection_id: int) -> str:
104103
connection = resolve_connection(connection_id)
105104
doc = MdDoc()
106105
doc.heading(1, f"Connection `{connection.connection_name}`")
107-
_render_connection_body(doc, connection)
106+
render_connection_body(doc, connection)
108107
return doc.render()
109108

110109

@@ -156,7 +155,7 @@ def test_connection(
156155
connection = Connection(sql_flavor=family, sql_flavor_code=code)
157156

158157
if connection_params is not None or connection_mode is not None:
159-
mode = connection_mode if inline else _effective_mode(connection, connection_mode)
158+
mode = connection_mode if inline else effective_mode(connection, connection_mode)
160159
apply_connection_params(connection, connection.sql_flavor_code, mode, connection_params or {})
161160

162161
normalize_auth_fields(connection)
@@ -165,7 +164,7 @@ def test_connection(
165164

166165
errors = validate_connection_fields(connection)
167166
if errors:
168-
_raise_validation_error(errors, "Cannot test connection. Required fields missing or invalid.")
167+
raise_validation_error(errors, "Cannot test connection. Required fields missing or invalid.")
169168

170169
status = test_connection_status(connection)
171170

@@ -186,132 +185,3 @@ def test_connection(
186185
if status.details:
187186
doc.code_block(status.details)
188187
return doc.render()
189-
190-
191-
# ---------------------------------------------------------------------------
192-
# Helpers
193-
# ---------------------------------------------------------------------------
194-
195-
196-
def _effective_mode(connection: Connection, connection_mode: str | None) -> str | None:
197-
"""Mode label to apply: the explicit override, else the connection's current mode."""
198-
if connection_mode is not None:
199-
return connection_mode
200-
inferred = infer_mode(connection)
201-
return str(inferred) if inferred is not None else None
202-
203-
204-
def _raise_validation_error(errors: list[str], header: str) -> None:
205-
bullets = "\n".join(f"- {err}" for err in errors)
206-
raise MCPUserError(f"{header}\n\n{bullets}")
207-
208-
209-
def _render_created_connection(connection: Connection) -> str:
210-
doc = MdDoc()
211-
doc.heading(1, f"Connection `{connection.connection_name}` created")
212-
_render_connection_body(doc, connection)
213-
return doc.render()
214-
215-
216-
def _render_connection_body(doc: MdDoc, connection: Connection) -> None:
217-
"""Render every non-secret connection field below the heading.
218-
219-
Shared by ``create_connection`` (the response after a successful create) and
220-
``get_connection`` (read tool) so the surfaced field set stays consistent.
221-
Encrypted columns are filtered out via ``ConnField.secret``.
222-
"""
223-
doc.field("ID", connection.connection_id, code=True)
224-
doc.field("Project", connection.project_code, code=True)
225-
doc.field("Type", format_flavor_label(connection.sql_flavor_code))
226-
227-
# Each populated, non-secret field under its flavor-specific label
228-
# (e.g. "Catalog" for Databricks, "Login URL" for Salesforce).
229-
for fld in connection_display_fields(connection):
230-
if fld.secret:
231-
continue
232-
value = getattr(connection, fld.column, None)
233-
if value in (None, ""):
234-
continue
235-
doc.field(fld.label, value, code=fld.column != "project_port")
236-
237-
doc.field("Authentication", _authentication_label(connection))
238-
if connection.max_threads is not None:
239-
doc.field("Max Threads", connection.max_threads)
240-
if connection.max_query_chars is not None:
241-
doc.field("Max Expression Length", connection.max_query_chars)
242-
243-
244-
def _authentication_label(connection: Connection) -> str:
245-
"""The connection's auth method: the active connection mode for multi-mode
246-
flavors, else the implicit method (service account key, else password).
247-
"""
248-
mode = infer_mode(connection)
249-
if mode is not None:
250-
return str(mode)
251-
if connection.service_account_key:
252-
return "Service Account Key"
253-
return "Password"
254-
255-
256-
def _snapshot(connection: Connection) -> dict[str, Any]:
257-
return {attr: getattr(connection, attr, None) for attr in _DIFF_ATTRS}
258-
259-
260-
def _render_field_value(attr: str, value: Any) -> str | None:
261-
if attr == "sql_flavor_code" and value is not None:
262-
return SQL_FLAVOR_CODE_TO_LABEL.get(value, value).value
263-
if isinstance(value, bool):
264-
return "Yes" if value else "No"
265-
if value is None or value == "":
266-
return None
267-
return str(value)
268-
269-
270-
_DIFF_ATTRS: tuple[str, ...] = (
271-
"connection_name",
272-
"sql_flavor_code",
273-
"project_host",
274-
"project_port",
275-
"project_db",
276-
"project_user",
277-
"project_pw_encrypted",
278-
"url",
279-
"connect_by_url",
280-
"connect_by_key",
281-
"private_key",
282-
"private_key_passphrase",
283-
"connect_with_identity",
284-
"warehouse",
285-
"http_path",
286-
"service_account_key",
287-
"max_threads",
288-
"max_query_chars",
289-
)
290-
291-
_DIFF_LABELS: dict[str, str] = {
292-
"connection_name": "Name",
293-
"sql_flavor_code": "Type",
294-
"project_host": "Host",
295-
"project_port": "Port",
296-
"project_db": "Database",
297-
"project_user": "Username",
298-
"project_pw_encrypted": "Password",
299-
"url": "URL",
300-
"connect_by_url": "Connect by URL",
301-
"connect_by_key": "Connect by Key-Pair",
302-
"private_key": "Private Key",
303-
"private_key_passphrase": "Private Key Passphrase",
304-
"connect_with_identity": "Connect with Managed Identity",
305-
"warehouse": "Warehouse",
306-
"http_path": "HTTP Path",
307-
"service_account_key": "Service Account Key",
308-
"max_threads": "Max Threads",
309-
"max_query_chars": "Max Expression Length",
310-
}
311-
312-
_ATTR_IS_SECRET: dict[str, bool] = {
313-
"project_pw_encrypted": True,
314-
"private_key": True,
315-
"private_key_passphrase": True,
316-
"service_account_key": True,
317-
}

testgen/mcp/tools/table_groups.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
from testgen.mcp.exceptions import MCPPermissionDenied, MCPResourceNotAccessible, MCPUserError
2222
from testgen.mcp.permissions import get_project_permissions, mcp_permission
2323
from testgen.mcp.tools.common import (
24+
DocGroup,
2425
format_flavor_label,
2526
format_page_footer,
2627
format_page_info,
@@ -32,6 +33,8 @@
3233
from testgen.mcp.tools.markdown import MdDoc
3334
from testgen.utils import friendly_score
3435

36+
_DOC_GROUP = DocGroup.MANAGE
37+
3538
_DUPLICATE_NAME_MESSAGE = "A Table Group with the same name already exists."
3639
_PII_FLAG_DENIED_MESSAGE = (
3740
"Changing PII detection requires permission to view PII. "

testgen/testing/__init__.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
"""Testing support importable by the core test suite and any plugin test suite.
2+
3+
Fixtures live in ``testgen.testing.fixtures``; import the ones you need into a
4+
``conftest.py`` to register them (pytest registers fixtures that are merely imported
5+
into a conftest). An importable module is the only way to share fixtures across separate
6+
test trees — sibling test directories do not share conftest fixtures.
7+
"""

0 commit comments

Comments
 (0)