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 1e08d61204f fix(mcp): preserve deduplicated chart tool input schemas
(#44575)
1e08d61204f is described below
commit 1e08d61204fff26a94f054736dc2041bfea49d8b
Author: Amin Ghadersohi <[email protected]>
AuthorDate: Fri Sep 25 07:44:43 2026 -0400
fix(mcp): preserve deduplicated chart tool input schemas (#44575)
Co-authored-by: Claude Haiku 4.5 <[email protected]>
---
docs/admin_docs/configuration/mcp-server.mdx | 8 +-
superset/mcp_service/mcp_config.py | 21 +-
superset/mcp_service/server.py | 131 +-------
.../mcp_service/test_chart_tool_inventory.py | 189 +++++++++++
.../unit_tests/mcp_service/test_tool_inventory.py | 157 ++++++++++
.../mcp_service/test_tool_search_transform.py | 347 +++------------------
6 files changed, 417 insertions(+), 436 deletions(-)
diff --git a/docs/admin_docs/configuration/mcp-server.mdx
b/docs/admin_docs/configuration/mcp-server.mdx
index a0a3e3db147..2dea57a0d6c 100644
--- a/docs/admin_docs/configuration/mcp-server.mdx
+++ b/docs/admin_docs/configuration/mcp-server.mdx
@@ -762,8 +762,8 @@ MCP_TOOL_SEARCH_CONFIG = {
],
"search_tool_name": "search_tools",
"call_tool_name": "call_tool",
- "include_schemas": False, # False=summary mode (name + parameters_hint)
- "compact_schemas": True, # Strip $defs (only applies when
include_schemas=True)
+ "include_schemas": True, # False=summary mode (name + parameters_hint)
+ "compact_schemas": True, # Default description limit when
include_schemas=True
"max_description_length": 300,
}
```
@@ -774,8 +774,8 @@ MCP_TOOL_SEARCH_CONFIG = {
| `strategy` | `"bm25"` | Search ranking algorithm. `"bm25"`
supports natural language; `"regex"` supports pattern matching
|
| `max_results` | `5` | Maximum tools returned per search
query
|
| `always_visible` | See above | Tools that always appear in
`list_tools`, regardless of search
|
-| `include_schemas` | `False` | When `False` (default, "summary
mode"), search results omit `inputSchema` entirely and include a lightweight
`parameters_hint` listing top-level parameter names. Set to `True` to include
the full `inputSchema` in search results. Full schemas are always used when a
tool is actually invoked via `call_tool`. |
-| `compact_schemas` | `True` | Strip `$defs` / `$ref` and replace
with `{"type": "object"}` in search results to reduce token cost. Only takes
effect when `include_schemas=True` — ignored in summary mode.
|
+| `include_schemas` | `True` | Include the full `inputSchema` in
search results by default. When `False` ("summary mode"), search results omit
`inputSchema` entirely and include a lightweight `parameters_hint` listing
top-level parameter names. Full schemas are always used when a tool is actually
invoked via `call_tool`. |
+| `compact_schemas` | `True` | Legacy setting selecting the default
description limit (300 when `True`, 0 when `False`) if `max_description_length`
is omitted. Input schemas preserve `$defs`, `$ref`, nullable unions, and
constraints in either mode; titles are omitted. Clients should resolve
references within each tool's `inputSchema`.
|
| `max_description_length` | `300` | Truncate tool descriptions in search
results (0 = no truncation). Applies in both summary and full-schema modes.
|
:::tip
diff --git a/superset/mcp_service/mcp_config.py
b/superset/mcp_service/mcp_config.py
index 502ffad1506..34583723de9 100644
--- a/superset/mcp_service/mcp_config.py
+++ b/superset/mcp_service/mcp_config.py
@@ -463,18 +463,17 @@ MCP_RESPONSE_SIZE_CONFIG: dict[str, Any] = {
# - "bm25": Natural language search using BM25 ranking (recommended)
# - "regex": Pattern-based search using regular expressions
#
-# Schema Compaction:
-# ------------------
-# When compact_schemas=True, search results strip $defs sections and replace
-# $ref pointers with {"type": "object"}, and truncate tool descriptions.
-# This reduces per-search token cost by ~40-60%. Full schemas remain
-# available when the tool is actually invoked via call_tool.
+# Schema Serialization:
+# ---------------------
+# Input schemas preserve $defs, $ref, nullable unions, and validation
constraints;
+# titles and output schemas are omitted. Clients should resolve references
within
+# each tool's inputSchema. Inlining references duplicates shared chart models.
+# The legacy compact_schemas setting only selects the default description limit
+# (300 when True, 0 when False) if max_description_length is omitted.
#
# Rollback:
# ---------
# - Set enabled=False to disable tool search entirely (full catalog exposed).
-# - Set compact_schemas=False to disable schema compaction only (full $defs
-# and descriptions in search results, tool search still active).
# - Set max_description_length=0 to disable description truncation only.
#
# Summary Mode (include_schemas):
@@ -489,8 +488,8 @@ MCP_RESPONSE_SIZE_CONFIG: dict[str, Any] = {
# so LLMs can see structured/discriminated-union configs (e.g. chart
# generation) without a second round trip. Set include_schemas=False to
# switch to summary mode if search_tools response size becomes a problem
-# again; compact_schemas is ignored when include_schemas=False (no schema to
-# compact); max_description_length still applies in summary mode.
+# again; compact_schemas is ignored when include_schemas=False.
+# max_description_length still applies in summary mode.
# =============================================================================
MCP_TOOL_SEARCH_CONFIG: dict[str, Any] = {
"enabled": True, # Enabled by default — reduces initial context by ~70%
@@ -502,7 +501,7 @@ MCP_TOOL_SEARCH_CONFIG: dict[str, Any] = {
],
"search_tool_name": "search_tools", # Name of the search tool
"call_tool_name": "call_tool", # Name of the call proxy tool
- "compact_schemas": True, # Strip $defs/$ref (requires
include_schemas=True)
+ "compact_schemas": True, # Legacy default description limit for full
schemas
"max_description_length": 300, # Truncate tool descriptions (0 = no
truncation)
"include_schemas": True, # full inputSchema in search results
}
diff --git a/superset/mcp_service/server.py b/superset/mcp_service/server.py
index 7727b33f047..45765560a54 100644
--- a/superset/mcp_service/server.py
+++ b/superset/mcp_service/server.py
@@ -299,111 +299,6 @@ def _strip_titles(obj: Any, in_properties_map: bool =
False) -> Any:
return obj
-def _simplify_optional_union(result: dict[str, Any]) -> dict[str, Any]:
- """Collapse ``anyOf``/``oneOf`` with exactly one non-null variant.
-
- Pydantic encodes ``Optional[X]`` as ``{"anyOf": [<X>, {"type": "null"}]}``.
- This replaces the union with the non-null variant while preserving any
- ``description`` or ``default`` from the parent node.
- """
- for union_key in ("anyOf", "oneOf"):
- variants = result.get(union_key)
- if not isinstance(variants, list) or len(variants) != 2:
- continue
- non_null = [v for v in variants if v.get("type") != "null"]
- if len(non_null) != 1:
- continue
- simplified = dict(non_null[0])
- for keep in ("description", "default"):
- if keep in result and keep not in simplified:
- simplified[keep] = result[keep]
- result.pop(union_key)
- result.pop("description", None)
- result.pop("default", None)
- result.update(simplified)
- return result
-
-
-def _resolve_ref(
- obj: dict[str, Any],
- defs: dict[str, Any],
- resolving: frozenset[str],
-) -> Any:
- """Resolve a ``$ref`` pointer by inlining its definition from *defs*.
-
- Falls back to ``{"type": "object"}`` when the definition is missing
- or would cause a circular reference.
- """
- ref_path: str = obj["$ref"]
- ref_name = ref_path.rsplit("/", 1)[-1] if "/" in ref_path else ""
- definition = defs.get(ref_name) if ref_name else None
-
- if definition is not None and ref_name not in resolving:
- inlined = _compact_schema(
- definition,
- _defs=defs,
- _resolving=resolving | {ref_name},
- )
- if isinstance(inlined, dict):
- if desc := obj.get("description"):
- inlined.setdefault("description", desc)
- return inlined
-
- replacement: dict[str, Any] = {"type": "object"}
- if desc := obj.get("description"):
- replacement["description"] = desc
- return replacement
-
-
-def _compact_schema(
- obj: Any,
- *,
- _defs: dict[str, Any] | None = None,
- _resolving: frozenset[str] | None = None,
-) -> Any:
- """Collapse ``$defs`` and ``$ref`` pointers in a JSON Schema.
-
- Search results only need enough schema detail for the LLM to identify
- which tool to call and construct a basic invocation. Full schemas
- (with all nested model definitions) are still available when the tool
- is actually invoked via ``call_tool``.
-
- Transformations applied:
-
- * ``$defs`` sections are removed entirely.
- * ``{"$ref": "..."}`` is resolved by inlining the referenced
- definition from ``$defs``. If the definition cannot be found
- (or would cause a circular reference), the ref is replaced with
- ``{"type": "object"}``.
- * ``anyOf``/``oneOf`` lists containing only a ``$ref`` and
- ``{"type": "null"}`` (Pydantic's Optional encoding) are collapsed
- to the simplified non-null variant.
- """
- if isinstance(obj, list):
- return [
- _compact_schema(item, _defs=_defs, _resolving=_resolving) for item
in obj
- ]
- if not isinstance(obj, dict):
- return obj
-
- # On the first (top-level) call, extract $defs for later resolution.
- if _defs is None:
- _defs = obj.get("$defs", {})
- if _resolving is None:
- _resolving = frozenset()
-
- if "$ref" in obj:
- return _resolve_ref(obj, _defs, _resolving)
-
- result: dict[str, Any] = {}
- for key, value in obj.items():
- if key == "$defs":
- continue
- result[key] = _compact_schema(value, _defs=_defs,
_resolving=_resolving)
-
- return _simplify_optional_union(result)
-
-
def _truncate_description(text: str, max_length: int) -> str:
"""Truncate a tool description for search results.
@@ -528,21 +423,20 @@ def _create_search_result_serializer(
) -> Any:
"""Build a search-result serializer from the tool-search config.
- When ``include_schemas`` is False (default), delegates to
+ When ``include_schemas`` is False, delegates to
:func:`_build_summary_serializer`, which strips ``inputSchema``
entirely and adds a ``parameters_hint`` field with comma-separated
top-level parameter names. This reduces per-search token cost by
~80% vs compact mode while still conveying what parameters a tool
accepts.
- When ``include_schemas`` is True, the full ``compact_schemas``/
- ``max_description_length`` pipeline applies (existing behavior):
-
- * ``$defs`` sections and ``$ref`` pointers are collapsed when
- ``compact_schemas`` is True (see :func:`_compact_schema`).
- * Tool descriptions are truncated to ``max_description_length`` chars.
+ When ``include_schemas`` is True, input schemas retain their definitions,
+ references, and validation constraints. Inlining references duplicates
shared
+ chart models and can make a single tool exceed client result limits.
- Full schemas remain available when the tool is invoked via ``call_tool``.
+ Titles and output schemas are stripped by the base serializer. The legacy
+ ``compact_schemas`` setting only selects the default description limit;
+ ``max_description_length`` explicitly controls description truncation.
"""
include_schemas = config.get("include_schemas", False)
@@ -550,23 +444,18 @@ def _create_search_result_serializer(
max_desc = config.get("max_description_length", 300)
return _build_summary_serializer(max_desc)
- # include_schemas=True: apply full compact_schemas/max_description_length
pipeline
compact = config.get("compact_schemas", True)
# Description truncation defaults to 300 when compact_schemas is on,
# but is disabled when compact_schemas is off (unless explicitly set).
- max_desc_default = 300 if compact else 0
- max_desc = config.get("max_description_length", max_desc_default)
+ max_desc = config.get("max_description_length", 300 if compact else 0)
- if not compact and not max_desc:
+ if not max_desc:
return _serialize_tools_without_output_schema
def _serializer(tools: Sequence[Any]) -> list[dict[str, Any]]:
results = _serialize_tools_without_output_schema(tools)
for data in results:
- if compact:
- if input_schema := data.get("inputSchema"):
- data["inputSchema"] = _compact_schema(input_schema)
- if max_desc and (desc := data.get("description")):
+ if desc := data.get("description"):
data["description"] = _truncate_description(desc, max_desc)
return results
diff --git a/tests/unit_tests/mcp_service/test_chart_tool_inventory.py
b/tests/unit_tests/mcp_service/test_chart_tool_inventory.py
new file mode 100644
index 00000000000..7db3fd81582
--- /dev/null
+++ b/tests/unit_tests/mcp_service/test_chart_tool_inventory.py
@@ -0,0 +1,189 @@
+# 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.
+
+"""Size and schema-first invocation regressions for chart tool inventory
entries."""
+
+from collections.abc import Callable, Iterator
+from copy import deepcopy
+from typing import Any
+
+import pytest
+from fastmcp.tools import FunctionTool
+from jsonschema import Draft202012Validator
+from pydantic import BaseModel
+
+from superset.mcp_service.app import mcp
+from superset.mcp_service.chart.schemas import (
+ GenerateChartRequest,
+ GenerateExploreLinkRequest,
+ UpdateChartRequest,
+)
+from superset.mcp_service.mcp_config import MCP_TOOL_SEARCH_CONFIG
+from superset.mcp_service.server import (
+ _create_search_result_serializer,
+ _strip_titles,
+)
+from superset.utils import json
+
+# Compact JSON, measured as UTF-8 bytes, including tool metadata.
+# Apache f8f293d2 -> reference-preserving inventory:
+# generate_chart: 97013 B -> 49531 B
+# update_chart: 104163 B -> 53395 B
+# generate_explore_link: 96581 B -> 49230 B
+# update_chart exceeds the proposed 50 kB target; 55 kB retains the entire
+# contract and stays below the 100 kB delivery cap. Byte budgets catch
reference
+# inlining without a tokenizer vocabulary download in the unit-test path.
+TOOL_BUDGETS = [
+ ("generate_chart", 50_000),
+ ("update_chart", 55_000),
+ ("generate_explore_link", 50_000),
+]
+
+
+def _references(node: Any) -> Iterator[str]:
+ """Collect schema references, including discriminator mapping targets."""
+ if isinstance(node, dict):
+ if "$ref" in node:
+ yield node["$ref"]
+ if "discriminator" in node:
+ yield from node["discriminator"].get("mapping", {}).values()
+ for value in node.values():
+ yield from _references(value)
+ elif isinstance(node, list):
+ for value in node:
+ yield from _references(value)
+
+
+def _resolve_pointer(schema: dict[str, Any], ref: str) -> Any:
+ """Resolve a local JSON Pointer against this tool's input schema."""
+ assert ref.startswith("#/")
+ target: Any = schema
+ for part in ref[2:].split("/"):
+ target = target[part.replace("~1", "/").replace("~0", "~")]
+ return target
+
+
[email protected]
[email protected](("name", "byte_budget"), TOOL_BUDGETS)
+async def test_chart_tool_inventory_size(name: str, byte_budget: int) -> None:
+ """Measure each real registered inventory entry, not a hand-built
schema."""
+ tool = await mcp.get_tool(name)
+ serializer = _create_search_result_serializer(MCP_TOOL_SEARCH_CONFIG)
+ entry = serializer([tool])[0]
+ assert "inputSchema" in entry # Summary mode must not mask a size
regression.
+ text = json.dumps(entry, ensure_ascii=False, separators=(",", ":"))
+ byte_count = len(text.encode("utf-8"))
+
+ assert byte_count <= byte_budget, (name, byte_count)
+
+
[email protected]
[email protected]("name", [row[0] for row in TOOL_BUDGETS])
+async def test_chart_tool_inventory_preserves_complete_schema(name: str) ->
None:
+ """All constraints and reference targets survive the production
serializer."""
+ tool = await mcp.get_tool(name)
+ original = deepcopy(tool.parameters)
+ entry = _create_search_result_serializer(MCP_TOOL_SEARCH_CONFIG)([tool])[0]
+ schema = entry["inputSchema"]
+
+ assert schema == _strip_titles(original)
+ assert tool.parameters == original
+ Draft202012Validator.check_schema(schema)
+ refs = list(_references(schema))
+ assert refs
+ for ref in refs:
+ assert _resolve_pointer(schema, ref) == _strip_titles(
+ _resolve_pointer(original, ref)
+ )
+
+ validator = Draft202012Validator(schema)
+ request: dict[str, Any] = {
+ "identifier" if name == "update_chart" else "dataset_id": 1,
+ "config": {"chart_type": "table", "columns": [{"name": "region"}]},
+ }
+ validator.validate({"request": request})
+ request["config"]["columns"] = [{"name": ""}]
+ assert not validator.is_valid({"request": request})
+
+
+def _generate_fixture(request: GenerateChartRequest) -> str:
+ """Accept a typed request without creating charts or querying a
database."""
+ return request.config.chart_type
+
+
+def _update_fixture(request: UpdateChartRequest) -> str:
+ """Accept a typed update without changing a saved chart."""
+ assert request.config is not None
+ return request.config.chart_type
+
+
+def _explore_fixture(request: GenerateExploreLinkRequest) -> str:
+ """Accept a typed request without writing to the permalink cache."""
+ assert request.config is not None
+ return request.config.chart_type
+
+
[email protected]
[email protected](
+ ("name", "fixture", "model"),
+ [
+ ("generate_chart", _generate_fixture, GenerateChartRequest),
+ ("update_chart", _update_fixture, UpdateChartRequest),
+ ("generate_explore_link", _explore_fixture,
GenerateExploreLinkRequest),
+ ],
+)
+async def test_chart_tool_schema_first_invocation(
+ name: str, fixture: Callable[..., str], model: type[BaseModel]
+) -> None:
+ """Follow published refs to build an invocation for a controlled tool
body."""
+ tool = await mcp.get_tool(name)
+ schema =
_create_search_result_serializer(MCP_TOOL_SEARCH_CONFIG)([tool])[0][
+ "inputSchema"
+ ]
+ request_schema = _resolve_pointer(schema,
schema["properties"]["request"]["$ref"])
+ assert request_schema == _strip_titles(schema["$defs"][model.__name__])
+ # Resolve the advertised table variant and column model rather than
inlining
+ # or substituting a generic object when a client encounters a reference.
+ table_ref = next(
+ ref
+ for ref in _references(request_schema["properties"]["config"])
+ if _resolve_pointer(schema, ref)
+ .get("properties", {})
+ .get("chart_type", {})
+ .get("const")
+ == "table"
+ )
+ table_schema = _resolve_pointer(schema, table_ref)
+ column_schema = _resolve_pointer(
+ schema, table_schema["properties"]["columns"]["items"]["$ref"]
+ )
+ assert "columns" in table_schema["required"]
+ assert "name" in column_schema["properties"]
+ request: dict[str, Any] = {
+ key: 1 for key in request_schema["required"] if key != "config"
+ }
+ request["config"] = {
+ "chart_type": table_schema["properties"]["chart_type"]["const"],
+ "columns": [{"name": "region"}],
+ }
+ arguments = {"request": request}
+ Draft202012Validator(schema).validate(arguments)
+ # Use the same request model through FastMCP's real argument validation,
+ # but replace persistence/query execution with the explicitly typed
fixture.
+ controlled_tool = FunctionTool.from_function(fixture)
+ result = await controlled_tool.run(arguments)
+ assert result.content[0].text == "table"
diff --git a/tests/unit_tests/mcp_service/test_tool_inventory.py
b/tests/unit_tests/mcp_service/test_tool_inventory.py
new file mode 100644
index 00000000000..03a451eaf9a
--- /dev/null
+++ b/tests/unit_tests/mcp_service/test_tool_inventory.py
@@ -0,0 +1,157 @@
+# 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.
+
+"""Per-tool size and schema fidelity budgets for the entire registered
inventory."""
+
+from copy import deepcopy
+
+import pytest
+from jsonschema import Draft202012Validator
+
+from superset.mcp_service.app import mcp
+from superset.mcp_service.mcp_config import MCP_TOOL_SEARCH_CONFIG
+from superset.mcp_service.server import _create_search_result_serializer,
_strip_titles
+from superset.utils import json
+
+# Compact JSON including tool metadata, measured as UTF-8 bytes.
+# Small-tool budgets are fixed snapshots: ceil(measured_bytes / 100) * 100 +
100,
+# leaving 100-199 bytes for incidental description edits. Do not recompute
limits
+# at test time: they must catch schema growth. Large chart tools retain their
+# explicit delivery budgets, well below the sizes produced by reference
inlining.
+TOOL_BUDGETS = {
+ "add_chart_to_existing_dashboard": 1_500,
+ "apply_dashboard_filters": 2_900,
+ "create_dataset": 1_800,
+ "create_theme": 1_100,
+ "create_virtual_dataset": 3_700,
+ "delete_chart": 1_100,
+ "delete_dashboard": 1_100,
+ "duplicate_dashboard": 1_900,
+ "execute_sql": 2_100,
+ "find_users": 1_500,
+ "generate_bug_report": 2_600,
+ "generate_chart": 50_000,
+ "generate_dashboard": 3_400,
+ "generate_explore_link": 50_000,
+ "get_annotation_layer_info": 1_000,
+ "get_chart_data": 2_900,
+ "get_chart_info": 3_600,
+ "get_chart_preview": 3_400,
+ "get_chart_sql": 2_100,
+ "get_chart_type_schema": 900,
+ "get_compatible_dimensions": 1_500,
+ "get_compatible_metrics": 1_500,
+ "get_dashboard_data": 2_400,
+ "get_dashboard_datasets": 1_100,
+ "get_dashboard_info": 3_100,
+ "get_dashboard_layout": 1_600,
+ "get_database_info": 1_400,
+ "get_dataset_info": 2_400,
+ "get_instance_info": 900,
+ "get_layer_annotation_info": 1_100,
+ "get_query_info": 1_100,
+ "get_report_info": 1_400,
+ "get_rls_filter_info": 1_000,
+ "get_role_info": 900,
+ "get_saved_query_info": 1_200,
+ "get_schema": 1_100,
+ "get_table": 3_900,
+ "get_tag_info": 1_000,
+ "get_task_info": 1_100,
+ "get_theme_info": 1_000,
+ "get_user_info": 1_000,
+ "health_check": 700,
+ "list_annotation_layers": 2_700,
+ # Include the deleted_state edit/restore audience and under-enumeration
+ # caveats from #44128: 5,149 and 4,626 bytes, plus the headroom above.
+ # Keep the complete-schema parity test below alongside these size limits.
+ "list_charts": 5_300,
+ "list_dashboards": 4_800,
+ "list_databases": 3_500,
+ "list_datasets": 4_600,
+ "list_layer_annotations": 2_900,
+ "list_metrics": 1_900,
+ "list_queries": 3_000,
+ "list_reports": 3_900,
+ "list_rls_filters": 2_600,
+ "list_roles": 2_700,
+ "list_saved_queries": 3_000,
+ "list_tags": 3_100,
+ "list_tasks": 2_700,
+ "list_themes": 3_000,
+ "list_users": 2_900,
+ "manage_dashboard_certification": 1_900,
+ "manage_dashboard_owners": 2_200,
+ "manage_dashboard_roles": 1_900,
+ "manage_native_filters": 6_700,
+ "open_sql_lab_with_context": 1_800,
+ "query_dataset": 3_700,
+ "remove_chart_from_dashboard": 1_300,
+ "restore_chart": 1_100,
+ "restore_dashboard": 1_000,
+ "save_sql_query": 1_600,
+ "update_chart": 55_000,
+ "update_chart_preview": 55_000,
+ "update_dashboard": 4_100,
+ "update_dataset_metric": 3_100,
+}
+
+
[email protected]
+async def test_inventory_budgets_cover_every_registered_tool() -> None:
+ """New or renamed tools must get explicit budgets instead of escaping
checks."""
+ tools = await mcp.list_tools(run_middleware=False)
+ assert {tool.name for tool in tools} == set(TOOL_BUDGETS)
+
+
[email protected]
[email protected]("name", TOOL_BUDGETS)
+async def test_tool_inventory_size(name: str) -> None:
+ """Limit every real serialized entry, including non-chart tools and
metadata."""
+ tool = await mcp.get_tool(name)
+ assert tool is not None
+ entry = _create_search_result_serializer(MCP_TOOL_SEARCH_CONFIG)([tool])[0]
+ assert "inputSchema" in entry # Summary mode must not hide schema growth.
+ text = json.dumps(entry, ensure_ascii=False, separators=(",", ":"))
+ byte_count = len(text.encode("utf-8"))
+ byte_budget = TOOL_BUDGETS[name]
+ assert byte_count <= byte_budget, (name, byte_count, byte_budget)
+
+
[email protected]
[email protected]("name", TOOL_BUDGETS)
+async def test_tool_inventory_preserves_complete_schema(name: str) -> None:
+ """Size optimizations must not discard nullable unions, refs or
constraints."""
+ tool = await mcp.get_tool(name)
+ assert tool is not None
+ original = deepcopy(tool.parameters)
+ entry = _create_search_result_serializer(MCP_TOOL_SEARCH_CONFIG)([tool])[0]
+ schema = entry["inputSchema"]
+ assert schema == _strip_titles(original)
+ assert tool.parameters == original
+ Draft202012Validator.check_schema(schema)
+
+
[email protected]
+async def test_inventory_budget_allows_incidental_description_edit() -> None:
+ """A small wording change must not exhaust the chart preview byte
budget."""
+ tool = await mcp.get_tool("get_chart_preview")
+ assert tool is not None
+ entry = _create_search_result_serializer(MCP_TOOL_SEARCH_CONFIG)([tool])[0]
+ entry["description"] += " A chart preview."
+ text = json.dumps(entry, ensure_ascii=False, separators=(",", ":"))
+ assert len(text.encode("utf-8")) <= TOOL_BUDGETS["get_chart_preview"]
diff --git a/tests/unit_tests/mcp_service/test_tool_search_transform.py
b/tests/unit_tests/mcp_service/test_tool_search_transform.py
index 2e7e70eaa23..bc51229894f 100644
--- a/tests/unit_tests/mcp_service/test_tool_search_transform.py
+++ b/tests/unit_tests/mcp_service/test_tool_search_transform.py
@@ -21,6 +21,7 @@ import logging
from types import SimpleNamespace
from unittest.mock import MagicMock, Mock, patch
+import pytest
from fastmcp.server.transforms.search import BM25SearchTransform,
RegexSearchTransform
from flask import Flask, g
@@ -29,7 +30,6 @@ from superset.mcp_service.mcp_config import
MCP_TOOL_SEARCH_CONFIG
from superset.mcp_service.privacy import requires_data_model_metadata_access
from superset.mcp_service.server import (
_apply_tool_search_transform,
- _compact_schema,
_create_search_result_serializer,
_extract_parameter_names,
_filter_tools_by_current_user_permission,
@@ -312,317 +312,66 @@ def test_normalize_ignores_keys_not_in_schema():
assert isinstance(result["unknown_key"], dict)
-# -- _compact_schema tests --
+# -- search schema fidelity tests --
-def test_compact_schema_removes_defs():
- """$defs section is stripped and refs are inlined from definitions."""
+def test_search_schema_preserves_references_and_constraints() -> None:
+ """Shared and recursive definitions remain resolvable without losing
constraints."""
schema = {
"type": "object",
"properties": {
- "filters": {"items": {"$ref": "#/$defs/MyFilter"}, "type":
"array"},
+ "first": {"$ref": "#/$defs/Node", "description": "First",
"maxItems": 2},
+ "second": {"$ref": "#/$defs/Node"},
},
+ "required": ["first"],
+ "additionalProperties": False,
"$defs": {
- "MyFilter": {
- "type": "object",
- "properties": {"col": {"type": "string"}},
- }
- },
- }
-
- result = _compact_schema(schema)
-
- assert "$defs" not in result
- # $ref is resolved by inlining the definition from $defs
- assert result["properties"]["filters"]["items"] == {
- "type": "object",
- "properties": {"col": {"type": "string"}},
- }
-
-
-def test_compact_schema_replaces_ref_with_object():
- """Direct $ref is replaced with {"type": "object"}."""
- schema = {"$ref": "#/$defs/SomeModel"}
-
- result = _compact_schema(schema)
-
- assert result == {"type": "object"}
-
-
-def test_compact_schema_preserves_ref_description():
- """$ref replacement preserves sibling description if present."""
- schema = {"$ref": "#/$defs/SomeModel", "description": "A model"}
-
- result = _compact_schema(schema)
-
- assert result == {"type": "object", "description": "A model"}
-
-
-def test_compact_schema_simplifies_optional_ref():
- """anyOf with $ref and null is collapsed to the non-null variant."""
- schema = {
- "anyOf": [
- {"$ref": "#/$defs/SomeModel"},
- {"type": "null"},
- ],
- "description": "Optional model",
- }
-
- result = _compact_schema(schema)
-
- assert "anyOf" not in result
- assert result["type"] == "object"
- assert result["description"] == "Optional model"
-
-
-def test_compact_schema_simplifies_optional_primitive():
- """anyOf with primitive type and null is collapsed."""
- schema = {
- "anyOf": [
- {"type": "integer"},
- {"type": "null"},
- ],
- "default": None,
- }
-
- result = _compact_schema(schema)
-
- assert "anyOf" not in result
- assert result["type"] == "integer"
- assert result["default"] is None
-
-
-def test_compact_schema_preserves_multi_variant_anyof():
- """anyOf with >2 variants or no null is left unchanged."""
- schema = {
- "anyOf": [
- {"type": "integer"},
- {"type": "string"},
- ]
- }
-
- result = _compact_schema(schema)
-
- assert "anyOf" in result
- assert len(result["anyOf"]) == 2
-
-
-def test_compact_schema_nested_in_items():
- """$ref nested inside items/properties falls back to object when no
$defs."""
- schema = {
- "type": "object",
- "properties": {
- "filters": {
- "type": "array",
- "items": {"$ref": "#/$defs/Filter"},
- },
- "name": {"type": "string"},
- },
- }
-
- result = _compact_schema(schema)
-
- # No $defs in schema → falls back to {"type": "object"}
- assert result["properties"]["filters"]["items"] == {"type": "object"}
- assert result["properties"]["name"] == {"type": "string"}
-
-
-def test_compact_schema_passthrough_simple():
- """Simple schema without $defs/$ref passes through unchanged."""
- schema = {
- "type": "object",
- "properties": {
- "page": {"type": "integer", "default": 1},
- "search": {"type": "string"},
- },
- }
-
- result = _compact_schema(schema)
-
- assert result == schema
-
-
-def test_compact_schema_handles_non_dict():
- """Non-dict inputs pass through unchanged."""
- assert _compact_schema("hello") == "hello"
- assert _compact_schema(42) == 42
- assert _compact_schema(None) is None
- assert _compact_schema([1, 2]) == [1, 2]
-
-
-def test_compact_schema_inlines_columnref_like_model() -> None:
- """$ref to a model with fields is inlined, preserving field structure."""
- schema = {
- "type": "object",
- "properties": {
- "metrics": {
+ "Node": {
"type": "array",
- "items": {"$ref": "#/$defs/ColumnRef"},
- },
- },
- "$defs": {
- "ColumnRef": {
- "type": "object",
- "properties": {
- "name": {"type": "string", "description": "Column name"},
- "aggregate": {"type": "string", "description": "SQL
aggregate"},
- "saved_metric": {"type": "boolean", "default": False},
+ "minItems": 1,
+ "items": {
+ "anyOf": [{"$ref": "#/$defs/Node"}, {"type": "integer"}],
},
- "required": ["name"],
- }
- },
- }
-
- result = _compact_schema(schema)
-
- assert "$defs" not in result
- inlined = result["properties"]["metrics"]["items"]
- assert inlined["type"] == "object"
- assert "name" in inlined["properties"]
- assert "aggregate" in inlined["properties"]
- assert "saved_metric" in inlined["properties"]
- assert inlined["required"] == ["name"]
-
-
-def test_compact_schema_inlines_nested_refs() -> None:
- """Nested $ref chains are resolved transitively."""
- schema = {
- "type": "object",
- "properties": {
- "config": {"$ref": "#/$defs/ChartConfig"},
- },
- "$defs": {
- "ChartConfig": {
- "type": "object",
- "properties": {
- "metrics": {
- "type": "array",
- "items": {"$ref": "#/$defs/ColumnRef"},
- },
- },
- },
- "ColumnRef": {
- "type": "object",
- "properties": {
- "name": {"type": "string"},
- },
- "required": ["name"],
},
},
}
+ serializer = _create_search_result_serializer({"include_schemas": True})
+ result = serializer([_make_mock_tool("tree", "A tree.", schema)])
- result = _compact_schema(schema)
+ assert result[0]["inputSchema"] == schema
- assert "$defs" not in result
- config = result["properties"]["config"]
- assert config["type"] == "object"
- col_ref = config["properties"]["metrics"]["items"]
- assert col_ref["type"] == "object"
- assert "name" in col_ref["properties"]
-
-def test_compact_schema_circular_ref_fallback() -> None:
- """Circular $ref falls back to {"type": "object"} to avoid infinite
recursion."""
+def test_search_schema_preserves_nullable_unions() -> None:
+ """Nullable unions, siblings, defaults, and discriminator mappings are
guidance."""
schema = {
"type": "object",
"properties": {
- "node": {"$ref": "#/$defs/TreeNode"},
- },
- "$defs": {
- "TreeNode": {
- "type": "object",
- "properties": {
- "children": {
- "type": "array",
- "items": {"$ref": "#/$defs/TreeNode"},
- },
+ "config": {
+ "oneOf": [{"$ref": "#/$defs/Config"}, {"type": "null"}],
+ "description": "Optional config",
+ "default": None,
+ "discriminator": {
+ "propertyName": "kind",
+ "mapping": {"table": "#/$defs/Config"},
},
- }
- },
- }
-
- result = _compact_schema(schema)
-
- assert "$defs" not in result
- node = result["properties"]["node"]
- assert node["type"] == "object"
- # The self-reference falls back to {"type": "object"}
- assert node["properties"]["children"]["items"] == {"type": "object"}
-
-
-def test_compact_schema_inline_with_sibling_description() -> None:
- """Inlined $ref preserves sibling description via setdefault."""
- schema = {
- "type": "object",
- "properties": {
- "col": {
- "$ref": "#/$defs/ColumnRef",
- "description": "The column to use",
},
- },
- "$defs": {
- "ColumnRef": {
- "type": "object",
- "properties": {"name": {"type": "string"}},
- }
- },
- }
-
- result = _compact_schema(schema)
-
- col = result["properties"]["col"]
- assert col["type"] == "object"
- assert "name" in col["properties"]
- assert col["description"] == "The column to use"
-
-
-def test_compact_schema_inline_optional_ref() -> None:
- """anyOf with $ref and null inlines the definition, not just {"type":
"object"}."""
- schema = {
- "type": "object",
- "properties": {
- "col": {
- "anyOf": [
- {"$ref": "#/$defs/ColumnRef"},
- {"type": "null"},
- ],
- "description": "Optional column",
+ "count": {
+ "anyOf": [{"type": "integer", "minimum": 1}, {"type": "null"}],
+ "default": None,
},
},
"$defs": {
- "ColumnRef": {
+ "Config": {
"type": "object",
- "properties": {"name": {"type": "string"}},
- }
- },
- }
-
- result = _compact_schema(schema)
-
- col = result["properties"]["col"]
- assert "anyOf" not in col
- assert col["type"] == "object"
- assert "name" in col["properties"]
- assert col["description"] == "Optional column"
-
-
-def test_compact_schema_inlines_empty_def() -> None:
- """Empty $defs entry ({}) is inlined as-is, not downgraded to {"type":
"object"}."""
- schema = {
- "type": "object",
- "properties": {
- "anything": {"$ref": "#/$defs/Anything"},
- },
- "$defs": {
- "Anything": {},
+ "properties": {"kind": {"const": "table"}},
+ "required": ["kind"],
+ },
},
}
+ serializer = _create_search_result_serializer({"include_schemas": True})
+ result = serializer([_make_mock_tool("nullable", "Nullable.", schema)])
- result = _compact_schema(schema)
-
- assert "$defs" not in result
- # Empty dict is a valid schema — should be inlined as {}, not {"type":
"object"}
- assert result["properties"]["anything"] == {}
+ assert result[0]["inputSchema"] == schema
# -- _truncate_description tests --
@@ -680,7 +429,7 @@ def _make_mock_tool(name, description, input_schema):
def test_create_serializer_compacts_schemas():
- """Compact serializer strips $defs and replaces $ref."""
+ """Search serialization keeps shared definitions and their references."""
tool = _make_mock_tool(
"list_charts",
"List charts with filtering.",
@@ -708,12 +457,8 @@ def test_create_serializer_compacts_schemas():
assert len(result) == 1
schema = result[0]["inputSchema"]
- assert "$defs" not in schema
- # $ref is inlined from $defs, preserving field structure
- assert schema["properties"]["filters"]["items"] == {
- "type": "object",
- "properties": {"col": {"type": "string"}},
- }
+ assert schema["$defs"]["ChartFilter"]["properties"] == {"col": {"type":
"string"}}
+ assert schema["properties"]["filters"]["items"] == {"$ref":
"#/$defs/ChartFilter"}
def test_create_serializer_truncates_descriptions():
@@ -731,8 +476,9 @@ def test_create_serializer_truncates_descriptions():
assert len(result[0]["description"]) <= 53 # 50 + potential "..."
-def test_create_serializer_disabled():
- """When compact_schemas=False and max_description_length=0, no
compaction."""
[email protected]("compact", [False, True])
+def test_create_serializer_disabled(compact: bool) -> None:
+ """An explicit zero description limit disables truncation in either
mode."""
tool = _make_mock_tool(
"test_tool",
"A long description " * 20,
@@ -743,7 +489,11 @@ def test_create_serializer_disabled():
)
serializer = _create_search_result_serializer(
- {"include_schemas": True, "compact_schemas": False,
"max_description_length": 0}
+ {
+ "include_schemas": True,
+ "compact_schemas": compact,
+ "max_description_length": 0,
+ }
)
result = serializer([tool])
@@ -1207,7 +957,7 @@ def
test_create_serializer_include_schemas_true_restores_full_schema():
def test_create_serializer_include_schemas_true_with_compact():
- """include_schemas=True + compact_schemas=True still compacts the
schema."""
+ """The legacy compact setting must not expand or weaken input schemas."""
schema = {
"type": "object",
"properties": {
@@ -1223,10 +973,7 @@ def
test_create_serializer_include_schemas_true_with_compact():
result = serializer([tool])
assert "inputSchema" in result[0]
- assert "$defs" not in result[0]["inputSchema"]
- assert result[0]["inputSchema"]["properties"]["filters"]["items"] == {
- "type": "object"
- }
+ assert result[0]["inputSchema"] == schema
# -- search_tools optional query tests --