Generare permalink filtrati per dashboard Superset via MCP
Nel post Integrare Claude AI assistant con Apache Superset abbiamo visto come usare un agent AI per ottenere dati da Apache Superset sfruttando le potenzialità dell’Intelligenza Artificiale. A partire dalla versione 6.1 Apache Superset include un server MCP (Model Context Protocol) integrato, che consente agli assistenti AI come Claude, ChatGPT e altri client compatibili con MCP, di interagire con l’istanza Superset attraverso il suo server MCP ed i tools già disponibili come get_dashboard_info, list_dashboards. In questo articolo vediamo come in generale sia possibile aggiungere un nuovo tool, ed in particolare come aggiungere un tool che fornisca il permalink ad una dashboard filtrata (es. “dammi il link alla dashboard X filtrata su giugno 2026”).
Aggiungere un tool al server MCP di Apache Superset
Per esporre un nuovo tool sul server MCP di Apache Superset, una volta definito il codice python della funzione specifica si procede in questo modo:
- Posizionare il file
create_dashboard_permalink.pynella cartella /app/superset/mcp_service/dashboard/tool/ dove sono presenti anche gli altri tools (es./app/superset/mcp_service/dashboard/tool/list_dashboards). - Registrare il nuovo tool in /app/superset/mcp_service/dashboard/tool/init.py, aggiungendo l’import della nuova funzione.
- Verificare i permessi: il tool è taggato mutate (crea uno stato persistente lato Superset) e usa class_permission_name=“Dashboard”; assicuriamoci che il ruolo con cui il server MCP autentica le richieste abbia i permessi necessari per creare permalink.
- Rigenerare l’immagine con i nuovi file e riavviare il container.
- Verificare la disponibilità del tool chiamando la ricerca dei tool del server MCP (es.
search_tools/tool_searchlato client) e controllando che il nuovo toolcreate_dashboard_permalink.pycompaia con titolo e descrizione corretti.
Configurazioni da applicare al container del server MCP
Punto critico: se il server MCP gira in un container Docker separato dal webserver Superset (come nel nostro caso), ha una propria inizializzazione dell’app Superset e quindi legge un proprio file di configurazione. Nel superset_config.py letto dal container del server MCP, aggiungere:
WEBDRIVER_BASEURL_USER_FRIENDLY = "https://<fqdn>:<port>"
sostituendo fqdn:port con l’host:porta (o dominio) effettivamente usato per accedere a Superset dal browser. Questa è la chiave nativa che Superset usa per costruire link verso l’esterno.
Tool create_dashboard_permalink
Il nuovo tool pensato per generare permalink con filtro applicato funziona secondo questa logica.
- Auto-detection del filtro temporale: legge
native_filter_configurationdaijson_metadatadella dashboard e individua il primo filtro nativo di tipofilter_time, senza che l’utente debba conoscerne l’id. - Linguaggio naturale per le date: accetta sia range espliciti (
"2026-04-01 : 2026-05-01") sia frasi della grammatica nativa di Superset ("last month","previous calendar month"). - Normalizzazione “Mese Anno”: la grammatica nativa di Superset non riconosce in modo affidabile frasi tipo
"April 2026"— se il parser fallisce, il filtro ricade silenziosamente sul default (nessuna restrizione,-∞ → oggi) invece di segnalare un errore. Il tool converte quindi automaticamente"<Mese> <Anno>"(inglese o italiano) in un range ISO esplicito prima di inviarlo a Superset. - URL pubblico corretto: costruisce l’URL finale leggendo una config esplicita (
SUPERSET_WEBSERVER_BASE_URLoWEBDRIVER_BASEURL_USER_FRIENDLY) invece di affidarsi al resolver di default, che in un contesto come una chiamata MCP può restituire l’hostname interno del container invece di quello pubblico. - Modalità standalone: l’URL generato include sempre
?standalone=1, che nasconde la barra di navigazione superiore di Superset (logo, menu Dashboards/Charts/SQL Lab) mantenendo però intatti autenticazione e permessi dell’utente che apre il link. - Filtri aggiuntivi: supporta un parametro
data_maskgrezzo per comporre altri filtri nativi (valore, range numerico, ecc.) insieme al filtro temporale. - Comportamento dell’agent AI: utilizzando il tool
native_filter_configuratione basandosi sulla description della funzione, risponde alla domanda “dammi la dashboard filtrata su giugno 2026”, fornendo il link pubblico alla dashboard con il filtro temporale applicato.
Codice del tool
# 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.
"""
Create dashboard permalink FastMCP tool
This module contains the FastMCP tool for generating a persistent,
shareable permalink to a dashboard with a specific filter state applied
(most commonly a time range, e.g. "April 2026" or "2026-01-01 : 2026-03-15"),
replicating the "Share > Copy permalink" button in the UI.
"""
import json
import logging
from datetime import datetime, timezone
from typing import Any
from fastmcp import Context
from pydantic import BaseModel, Field
from superset_core.mcp.decorators import tool, ToolAnnotations
from superset.commands.dashboard.permalink.create import CreateDashboardPermalinkCommand
from superset.dashboards.permalink.types import DashboardPermalinkState
from superset.extensions import event_logger
from superset.mcp_service.dashboard.schemas import DashboardError
from superset.utils.urls import get_url_path # fallback only, see _build_permalink_url
logger = logging.getLogger(__name__)
class CreateDashboardPermalinkRequest(BaseModel):
identifier: str = Field(
..., description="Dashboard ID, UUID, or slug"
)
time_range: str | None = Field(
default=None,
description=(
"Time range to apply, in Superset's native time-range grammar. "
"Accepts human phrases ('April 2026', 'last month', "
"'previous calendar year') or explicit ranges "
"('2026-01-01 : 2026-03-16'). Applied to the dashboard's native "
"time filter (auto-detected unless time_filter_id is given)."
),
)
time_filter_id: str | None = Field(
default=None,
description=(
"Native filter id to apply time_range to. Only needed if the "
"dashboard has more than one time filter and auto-detection "
"should be overridden. Use get_dashboard_info to inspect "
"native filters."
),
)
data_mask: dict[str, Any] | None = Field(
default=None,
description=(
"Optional raw dataMask entries for other native filters "
"(value filters, numerical ranges, etc.), in the format "
"{filter_id: {id, extraFormData, filterState}}. Merged with "
"the time filter entry if time_range is also given."
),
)
active_tabs: list[str] | None = Field(
default=None, description="IDs of active tabs, if the dashboard has tabs"
)
class DashboardPermalinkInfo(BaseModel):
key: str
url: str
dashboard_id: int
applied_filters: dict[str, Any]
def _get_native_filter_configuration(dashboard: Any) -> list[dict[str, Any]]:
"""Extract native_filter_configuration from a dashboard's json_metadata."""
if not dashboard.json_metadata:
return []
try:
metadata = json.loads(dashboard.json_metadata)
except (TypeError, ValueError):
return []
return metadata.get("native_filter_configuration", []) or []
_MONTH_NAMES = {
# English
"january": 1, "february": 2, "march": 3, "april": 4, "may": 5, "june": 6,
"july": 7, "august": 8, "september": 9, "october": 10, "november": 11,
"december": 12,
# Italiano
"gennaio": 1, "febbraio": 2, "marzo": 3, "aprile": 4, "maggio": 5,
"giugno": 6, "luglio": 7, "agosto": 8, "settembre": 9, "ottobre": 10,
"novembre": 11, "dicembre": 12,
}
def _normalize_time_range(time_range: str) -> str:
"""Normalize a "Month YYYY" phrase into an explicit ISO range.
Superset's native time-range grammar reliably parses explicit ranges
("2026-05-01 : 2026-06-01") and specific relative phrases ("last month",
"previous calendar month"), but does NOT reliably parse a bare
"<Month> <Year>" phrase (e.g. "May 2026"). When it fails to parse, the
native time filter silently falls back to no restriction instead of
raising an error - so we convert month+year phrases to an explicit range
ourselves rather than relying on the native parser for that case.
Anything else (already an explicit range, or a relative phrase like
"last month") is passed through untouched.
"""
if ":" in time_range:
return time_range # already an explicit range
parts = time_range.strip().split()
if len(parts) == 2 and parts[1].isdigit():
month_name, year_str = parts[0].lower(), parts[1]
month = _MONTH_NAMES.get(month_name)
if month:
year = int(year_str)
start = datetime(year, month, 1)
end_year, end_month = (year + 1, 1) if month == 12 else (year, month + 1)
end = datetime(end_year, end_month, 1)
return f"{start:%Y-%m-%d} : {end:%Y-%m-%d}"
return time_range
def _find_time_filter_id(native_filters: list[dict[str, Any]]) -> str | None:
"""Return the id of the first native time-range filter found, if any."""
for native_filter in native_filters:
if native_filter.get("filterType") == "filter_time":
return native_filter.get("id")
return None
def _build_permalink_url(key: str) -> str:
"""Build a publicly reachable permalink URL for the given key.
get_url_path() resolves against the Flask app's default SERVER_NAME,
which inside a container/background task context (no live HTTP request)
can resolve to the internal service hostname (e.g. "superset_app")
instead of the public-facing one. We prefer an explicit public base URL
from config if available, and fall back to get_url_path() otherwise.
Adjust the config keys below to whichever your instance actually sets -
check how the dashboard "url" field returned by list_dashboards/
get_dashboard_info is built (likely via request.host during a real HTTP
request) and align this helper to the same source of truth.
"""
from flask import current_app
public_base = (
current_app.config.get("SUPERSET_WEBSERVER_BASE_URL")
or current_app.config.get("WEBDRIVER_BASEURL_USER_FRIENDLY")
)
if public_base:
return f"{public_base.rstrip('/')}/superset/dashboard/p/{key}/?standalone=1"
# Fallback: may resolve to an internal hostname in non-request contexts.
return f"{get_url_path('Superset.dashboard_permalink', key=key)}?standalone=1"
@tool(
tags=["mutate"],
class_permission_name="Dashboard",
annotations=ToolAnnotations(
title="Create dashboard permalink",
readOnlyHint=False,
destructiveHint=False,
),
)
async def create_dashboard_permalink(
request: CreateDashboardPermalinkRequest, ctx: Context
) -> DashboardPermalinkInfo | DashboardError:
"""
Generate a Superset permalink (/superset/dashboard/p/<key>/) for a
dashboard with a specific filter state applied - typically a time
range like "April 2026" or "2026-01-01 : 2026-03-15".
Typical usage - "give me the link to dashboard X filtered to April":
```json
{
"identifier": "123",
"time_range": "April 2026"
}
```
Explicit date range:
```json
{
"identifier": "123",
"time_range": "2026-01-01 : 2026-03-16"
}
```
Combined with another native filter (e.g. a region value filter),
passed as raw dataMask:
```json
{
"identifier": "123",
"time_range": "last month",
"data_mask": {
"NATIVE_FILTER-abc123": {
"id": "NATIVE_FILTER-abc123",
"extraFormData": {"filters": [{"col": "region", "op": "IN", "val": ["EMEA"]}]},
"filterState": {"value": ["EMEA"]}
}
}
}
```
If the dashboard has no native time filter and time_range is provided,
returns a DashboardError explaining that a data_mask must be supplied
manually instead.
"""
await ctx.info(
"Creating dashboard permalink: identifier=%s, time_range=%s, "
"time_filter_id=%s"
% (request.identifier, request.time_range, request.time_filter_id)
)
try:
from superset.daos.dashboard import DashboardDAO
with event_logger.log_context(action="mcp.create_dashboard_permalink.lookup"):
dashboard = DashboardDAO.get_by_id_or_slug(request.identifier)
if not dashboard:
await ctx.warning(
"Dashboard not found: identifier=%s" % (request.identifier,)
)
return DashboardError(
error=f"Dashboard not found: {request.identifier}",
error_type="DashboardNotFoundError",
timestamp=datetime.now(timezone.utc),
)
combined_data_mask: dict[str, Any] = dict(request.data_mask or {})
if request.time_range:
filter_id = request.time_filter_id
if not filter_id:
native_filters = _get_native_filter_configuration(dashboard)
filter_id = _find_time_filter_id(native_filters)
if not filter_id:
await ctx.warning(
"No native time filter found on dashboard id=%s and "
"time_filter_id not provided" % (dashboard.id,)
)
return DashboardError(
error=(
"This dashboard has no native time filter to apply "
"time_range to. Pass time_filter_id explicitly, or "
"supply a full data_mask instead."
),
error_type="NoTimeFilterFoundError",
timestamp=datetime.now(timezone.utc),
)
normalized_time_range = _normalize_time_range(request.time_range)
if normalized_time_range != request.time_range:
await ctx.debug(
"Normalized time_range '%s' -> '%s'"
% (request.time_range, normalized_time_range)
)
combined_data_mask[filter_id] = {
"id": filter_id,
"extraFormData": {"time_range": normalized_time_range},
"filterState": {"value": normalized_time_range},
}
dashboard_state: DashboardPermalinkState = {
"dataMask": combined_data_mask,
"activeTabs": request.active_tabs or [],
"anchor": "",
"urlParams": [],
}
with event_logger.log_context(action="mcp.create_dashboard_permalink.create"):
key = CreateDashboardPermalinkCommand(
dashboard_id=str(dashboard.id),
state=dashboard_state,
).run()
url = _build_permalink_url(key)
await ctx.info(
"Dashboard permalink created: dashboard_id=%s, key=%s, url=%s"
% (dashboard.id, key, url)
)
return DashboardPermalinkInfo(
key=key,
url=url,
dashboard_id=dashboard.id,
applied_filters=combined_data_mask,
)
except Exception as e:
await ctx.error(
"Dashboard permalink creation failed: identifier=%s, error=%s, "
"error_type=%s" % (request.identifier, str(e), type(e).__name__)
)
return DashboardError(
error=f"Failed to create dashboard permalink: {str(e)}",
error_type="InternalError",
timestamp=datetime.now(timezone.utc),
)