This is an automated email from the ASF dual-hosted git repository.

aminghadersohi pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/superset.git


The following commit(s) were added to refs/heads/master by this push:
     new e606cadff7c chore(mcp): fix malformed tool and prompt docstrings 
(#44572)
e606cadff7c is described below

commit e606cadff7caf4c7a1134e62b3c13adbee26c459
Author: Amin Ghadersohi <[email protected]>
AuthorDate: Sat Oct 3 02:49:19 2026 +1000

    chore(mcp): fix malformed tool and prompt docstrings (#44572)
    
    Co-authored-by: Claude Opus 5 <[email protected]>
---
 .../chart/prompts/create_chart_guided.py           |   4 +-
 superset/mcp_service/system/prompts/quickstart.py  |   4 +-
 superset/mcp_service/system/tool/get_schema.py     |   7 +-
 superset/mcp_service/system/tool/health_check.py   |   5 +-
 .../unit_tests/mcp_service/test_mcp_docstrings.py  | 128 +++++++++++++++++++++
 5 files changed, 138 insertions(+), 10 deletions(-)

diff --git a/superset/mcp_service/chart/prompts/create_chart_guided.py 
b/superset/mcp_service/chart/prompts/create_chart_guided.py
index f1868fa7497..75cea4451fe 100644
--- a/superset/mcp_service/chart/prompts/create_chart_guided.py
+++ b/superset/mcp_service/chart/prompts/create_chart_guided.py
@@ -30,8 +30,8 @@ async def create_chart_guided_prompt(
     Guided chart creation with step-by-step workflow.
 
     Args:
-        chart_type: Preferred chart type (auto, line, bar, table, scatter, 
area)
-        business_goal: Purpose (exploration, reporting, monitoring, 
presentation)
+        chart_type (str): Preferred chart type (auto, line, bar, table, 
scatter, area)
+        business_goal (str): Purpose (exploration, reporting, monitoring, 
presentation)
     """
 
     chart_intelligence = {
diff --git a/superset/mcp_service/system/prompts/quickstart.py 
b/superset/mcp_service/system/prompts/quickstart.py
index 4427527029c..e691442e513 100644
--- a/superset/mcp_service/system/prompts/quickstart.py
+++ b/superset/mcp_service/system/prompts/quickstart.py
@@ -40,8 +40,8 @@ async def quickstart_prompt(
     Guide new users through their first experience with the platform.
 
     Args:
-        user_type: Type of user (analyst, executive, developer)
-        focus_area: Area of interest (sales, marketing, operations, general)
+        user_type (str): Type of user (analyst, executive, developer)
+        focus_area (str): Area of interest (sales, marketing, operations, 
general)
     """
     app_name = _get_app_name()
 
diff --git a/superset/mcp_service/system/tool/get_schema.py 
b/superset/mcp_service/system/tool/get_schema.py
index d80b64478d8..3fa16810424 100644
--- a/superset/mcp_service/system/tool/get_schema.py
+++ b/superset/mcp_service/system/tool/get_schema.py
@@ -221,10 +221,13 @@ async def get_schema(
     Column metadata is extracted dynamically from SQLAlchemy models.
 
     Args:
-        model_type: One of "chart", "dataset", "dashboard", "database", or 
"report"
+        request (GetSchemaRequest): Request schema for unified get_schema 
tool. Its
+            model_type is one of "chart", "dataset", "dashboard", "database" or
+            "report".
 
     Returns:
-        Comprehensive schema information for the requested model type
+        (GetSchemaResponse | PrivacyError): Comprehensive schema information 
for
+            the requested model type.
     """
     await ctx.info(f"Getting schema for model_type={request.model_type}")
 
diff --git a/superset/mcp_service/system/tool/health_check.py 
b/superset/mcp_service/system/tool/health_check.py
index 35145038cd8..8868502909b 100644
--- a/superset/mcp_service/system/tool/health_check.py
+++ b/superset/mcp_service/system/tool/health_check.py
@@ -52,11 +52,8 @@ async def health_check() -> HealthCheckResponse:
     Returns basic system information and confirms the service is running.
     This is useful for testing connectivity and basic functionality.
 
-    Parameters:
-        None - This tool does not accept any parameters
-
     Returns:
-        HealthCheckResponse: Health status and system information including:
+        (HealthCheckResponse): Health status and system information including:
             - status: "healthy" or "error"
             - timestamp: ISO format timestamp
             - service: Service name derived from APP_NAME config
diff --git a/tests/unit_tests/mcp_service/test_mcp_docstrings.py 
b/tests/unit_tests/mcp_service/test_mcp_docstrings.py
new file mode 100644
index 00000000000..164bb95ea7d
--- /dev/null
+++ b/tests/unit_tests/mcp_service/test_mcp_docstrings.py
@@ -0,0 +1,128 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+"""Guard against malformed MCP tool and prompt docstrings.
+
+FastMCP parses every registered tool and prompt docstring with griffe to
+derive the description and the per-argument descriptions it publishes to
+clients. A docstring griffe cannot parse makes griffe log a warning naming
+the offending line, and any entry naming something that is not a real
+parameter is dropped instead of reaching the published schema.
+
+FastMCP pins the griffe logger to ERROR when it is imported, so whether those
+warnings reach a given deployment's logs depends on that deployment's logging
+configuration. This module raises the level back to WARNING so the build fails
+on the malformed docstring itself rather than on whether anything happens to
+be listening.
+"""
+
+import asyncio
+import inspect
+import logging
+from collections.abc import Iterator
+from contextlib import contextmanager
+from typing import Any
+
+from fastmcp.utilities.docstring_parsing import parse_docstring
+
+from superset.mcp_service.app import mcp
+
+# Griffe emits its parsing diagnostics through a single logger of this name.
+GRIFFE_LOGGER = "griffe"
+
+
+@contextmanager
+def _captured_griffe_warnings() -> Iterator[list[str]]:
+    """Collect griffe warnings, bypassing any level FastMCP has imposed."""
+    messages: list[str] = []
+
+    class _Collector(logging.Handler):
+        def emit(self, record: logging.LogRecord) -> None:
+            messages.append(record.getMessage())
+
+    logger = logging.getLogger(GRIFFE_LOGGER)
+    handler = _Collector(level=logging.WARNING)
+    previous_level = logger.level
+    logger.setLevel(logging.WARNING)
+    logger.addHandler(handler)
+    try:
+        yield messages
+    finally:
+        logger.removeHandler(handler)
+        logger.setLevel(previous_level)
+
+
+def _registered_functions() -> list[tuple[str, Any]]:
+    """Pair every registered tool and prompt with its underlying function.
+
+    A list rather than a dict: ``list_tools()`` returns every version of a
+    tool without deduplicating, and a prompt may share a tool's name, so
+    keying by name alone would silently drop components from the guard.
+    """
+    tools = asyncio.run(mcp.list_tools())
+    prompts = asyncio.run(mcp.list_prompts())
+    functions = []
+    for kind, components in (("tool", tools), ("prompt", prompts)):
+        for component in components:
+            fn = getattr(component, "fn", None)
+            if fn is not None:
+                functions.append((f"{kind} {component.name}", fn))
+    return functions
+
+
+def test_registered_functions_are_discoverable() -> None:
+    """The guard below is only meaningful if it sees the real functions."""
+    labels = {label for label, _ in _registered_functions()}
+    assert "tool health_check" in labels
+    assert "tool get_schema" in labels
+    assert "prompt quickstart" in labels
+
+
+def test_mcp_docstrings_parse_without_griffe_warnings() -> None:
+    """No registered tool or prompt docstring may produce a griffe warning."""
+    offenders: dict[str, list[str]] = {}
+    for label, fn in _registered_functions():
+        with _captured_griffe_warnings() as messages:
+            parse_docstring(fn)
+        if messages:
+            offenders[label] = messages
+
+    assert not offenders, (
+        "Malformed MCP docstrings — griffe cannot parse these, so each one "
+        "logs a warning naming the offending line every time the module is "
+        "imported:\n"
+        + "\n".join(f"  {label}: {msgs}" for label, msgs in 
sorted(offenders.items()))
+    )
+
+
+def test_documented_arguments_reach_the_published_schema() -> None:
+    """Every documented argument must match a real parameter of the 
function."""
+    mismatched: dict[str, list[str]] = {}
+    for label, fn in _registered_functions():
+        parsed = parse_docstring(fn)
+        if not parsed.parameters:
+            continue
+        signature_params = set(inspect.signature(fn).parameters)
+        unknown = sorted(set(parsed.parameters) - signature_params)
+        if unknown:
+            mismatched[label] = unknown
+
+    assert not mismatched, (
+        "Docstrings document arguments that do not exist on the function, so "
+        "their descriptions never reach the published schema:\n"
+        + "\n".join(f"  {label}: {args}" for label, args in 
sorted(mismatched.items()))
+    )

Reply via email to