Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions docs/admin_docs/configuration/mcp-server.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}
```
Expand All @@ -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`. |
Comment thread
aminghadersohi marked this conversation as resolved.
| `max_description_length` | `300` | Truncate tool descriptions in search results (0 = no truncation). Applies in both summary and full-schema modes. |

:::tip
Expand Down
21 changes: 10 additions & 11 deletions superset/mcp_service/mcp_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -463,18 +463,17 @@ class MCPAuthConfigError(ValueError):
# - "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):
Expand All @@ -489,8 +488,8 @@ class MCPAuthConfigError(ValueError):
# 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%
Expand All @@ -502,7 +501,7 @@ class MCPAuthConfigError(ValueError):
],
"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
}
Expand Down
131 changes: 10 additions & 121 deletions superset/mcp_service/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -528,45 +423,39 @@ 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)

if not include_schemas:
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)
Comment thread
aminghadersohi marked this conversation as resolved.
# 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

Expand Down
Loading
Loading