Skip to content

Commit 32b1d82

Browse files
rboni-dkclaude
andcommitted
feat(mcp): add plugin MCP-tools hook and promote shared connection helpers (TG-1124)
Add PluginSpec.get_mcp_tools() (default empty) and register plugin-provided tools in build_mcp_server through the same safe_tool wrapper as core tools. Promote the shared connection render/validation helpers (effective_mode, raise_validation_error, render_connection_body, authentication_label) to mcp/tools/common.py so the read/test tools and any plugin tools share them. Tag the connection and table-group tool modules with _DOC_GROUP=MANAGE so they group under 'Manage TestGen configuration'. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 6bbfd75 commit 32b1d82

5 files changed

Lines changed: 86 additions & 62 deletions

File tree

testgen/mcp/server.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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: 9 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -19,20 +19,24 @@
1919
from testgen.mcp.permissions import get_project_permissions, mcp_permission
2020
from testgen.mcp.tools.common import (
2121
SQL_FLAVOR_CODE_TO_LABEL,
22+
DocGroup,
2223
apply_connection_params,
23-
connection_display_fields,
24+
effective_mode,
2425
format_flavor_label,
2526
format_page_footer,
2627
format_page_info,
27-
infer_mode,
2828
parse_sql_flavor,
29+
raise_validation_error,
30+
render_connection_body,
2931
resolve_connection,
3032
validate_connection_fields,
3133
validate_limit,
3234
validate_page,
3335
)
3436
from testgen.mcp.tools.markdown import MdDoc
3537

38+
_DOC_GROUP = DocGroup.MANAGE
39+
3640

3741
@with_database_session
3842
@mcp_permission("view")
@@ -99,7 +103,7 @@ def get_connection(connection_id: int) -> str:
99103
connection = resolve_connection(connection_id)
100104
doc = MdDoc()
101105
doc.heading(1, f"Connection `{connection.connection_name}`")
102-
_render_connection_body(doc, connection)
106+
render_connection_body(doc, connection)
103107
return doc.render()
104108

105109

@@ -151,7 +155,7 @@ def test_connection(
151155
connection = Connection(sql_flavor=family, sql_flavor_code=code)
152156

153157
if connection_params is not None or connection_mode is not None:
154-
mode = connection_mode if inline else _effective_mode(connection, connection_mode)
158+
mode = connection_mode if inline else effective_mode(connection, connection_mode)
155159
apply_connection_params(connection, connection.sql_flavor_code, mode, connection_params or {})
156160

157161
normalize_auth_fields(connection)
@@ -160,7 +164,7 @@ def test_connection(
160164

161165
errors = validate_connection_fields(connection)
162166
if errors:
163-
_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.")
164168

165169
status = test_connection_status(connection)
166170

@@ -181,59 +185,3 @@ def test_connection(
181185
if status.details:
182186
doc.code_block(status.details)
183187
return doc.render()
184-
185-
186-
# ---------------------------------------------------------------------------
187-
# Helpers
188-
# ---------------------------------------------------------------------------
189-
190-
191-
def _effective_mode(connection: Connection, connection_mode: str | None) -> str | None:
192-
"""Mode label to apply: the explicit override, else the connection's current mode."""
193-
if connection_mode is not None:
194-
return connection_mode
195-
inferred = infer_mode(connection)
196-
return str(inferred) if inferred is not None else None
197-
198-
199-
def _raise_validation_error(errors: list[str], header: str) -> None:
200-
bullets = "\n".join(f"- {err}" for err in errors)
201-
raise MCPUserError(f"{header}\n\n{bullets}")
202-
203-
204-
def _render_connection_body(doc: MdDoc, connection: Connection) -> None:
205-
"""Render every non-secret connection field below the heading.
206-
207-
Encrypted columns are filtered out via ``ConnField.secret``.
208-
"""
209-
doc.field("ID", connection.connection_id, code=True)
210-
doc.field("Project", connection.project_code, code=True)
211-
doc.field("Type", format_flavor_label(connection.sql_flavor_code))
212-
213-
# Each populated, non-secret field under its flavor-specific label
214-
# (e.g. "Catalog" for Databricks, "Login URL" for Salesforce).
215-
for fld in connection_display_fields(connection):
216-
if fld.secret:
217-
continue
218-
value = getattr(connection, fld.column, None)
219-
if value in (None, ""):
220-
continue
221-
doc.field(fld.label, value, code=fld.column != "project_port")
222-
223-
doc.field("Authentication", _authentication_label(connection))
224-
if connection.max_threads is not None:
225-
doc.field("Max Threads", connection.max_threads)
226-
if connection.max_query_chars is not None:
227-
doc.field("Max Expression Length", connection.max_query_chars)
228-
229-
230-
def _authentication_label(connection: Connection) -> str:
231-
"""The connection's auth method: the active connection mode for multi-mode
232-
flavors, else the implicit method (service account key, else password).
233-
"""
234-
mode = infer_mode(connection)
235-
if mode is not None:
236-
return str(mode)
237-
if connection.service_account_key:
238-
return "Service Account Key"
239-
return "Password"

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/utils/plugins.py

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
import importlib
44
import importlib.metadata
55
import inspect
6-
from collections.abc import Generator
6+
from collections.abc import Callable, Generator
77
from typing import ClassVar, get_args
88

99
from testgen.ui.assets import get_asset_path
@@ -61,6 +61,16 @@ def configure_ui(cls) -> None:
6161
is actually running. Called by ``Plugin.load_streamlit()``, never by ``Plugin.load()``.
6262
"""
6363

64+
@classmethod
65+
def get_mcp_tools(cls) -> list[Callable]:
66+
"""Return MCP tool callables to register into the MCP server.
67+
68+
Override in plugins to register additional MCP tools. Implementations import their
69+
tool modules inside the method (like ``configure_ui``) so plugin import stays light
70+
for processes that never build the MCP server.
71+
"""
72+
return []
73+
6474

6575
class PluginHook:
6676
"""Singleton holding resolved plugin values, pre-loaded with defaults."""

0 commit comments

Comments
 (0)