Generate filtered permalinks for Superset dashboards via MCP
In the post Integrating Claude AI Assistant with Apache Superset, we saw how to use an AI agent to retrieve data from Apache Superset by leveraging the capabilities of Artificial Intelligence. Starting with version 6.1, Apache Superset includes an integrated MCP (Model Context Protocol) server, which allows AI assistants such as Claude, ChatGPT, and other MCP-compatible clients to interact with a Superset instance through its MCP server and the tools already available, such as get_dashboard_info and list_dashboards. In this article, we will see how a new tool can be added in general, and in particular how to add a tool that provides a permalink to a filtered dashboard (e.g. “give me the link to dashboard X filtered to June 2026”).
Adding a Tool to the Apache Superset MCP Server
To expose a new tool through the Apache Superset MCP server, once the Python code for the specific function has been defined, proceed as follows:
- Place the
create_dashboard_permalink.pyfile in the /app/superset/mcp_service/dashboard/tool/ directory, where the other tools are also located (e.g./app/superset/mcp_service/dashboard/tool/list_dashboards). - Register the new tool in /app/superset/mcp_service/dashboard/tool/init.py by adding the import for the new function.
- Check the permissions: the tool is tagged as
mutate(it creates persistent state on the Superset side) and usesclass_permission_name="Dashboard"; make sure that the role used by the MCP server to authenticate requests has the permissions required to create permalinks. - Rebuild the image with the new files and restart the container.
- Verify that the tool is available by querying the MCP server’s tool discovery mechanism (e.g.
search_tools/tool_searchon the client side) and checking that the newcreate_dashboard_permalink.pytool appears with the correct title and description.
Configuration to Apply to the MCP Server Container
Critical point: if the MCP server runs in a Docker container separate from the Superset web server (as in our case), it has its own Superset application initialization and therefore reads its own configuration file. In the superset_config.py read by the MCP server container, add:
WEBDRIVER_BASEURL_USER_FRIENDLY = "https://<fqdn>:<port>"
Replace fqdn:port with the host and port (or domain) actually used to access Superset from the browser. This is the native configuration key that Superset uses to build external links.
create_dashboard_permalink Tool
The new tool designed to generate permalinks with an applied filter works according to the following logic.
- Automatic time-filter detection: it reads
native_filter_configurationfrom the dashboard’sjson_metadataand identifies the first native filter of typefilter_time, without requiring the user to know its ID. - Natural language for dates: it accepts both explicit ranges (
"2026-04-01 : 2026-05-01") and phrases from Superset’s native grammar ("last month","previous calendar month"). - “Month Year” normalization: Superset’s native grammar does not reliably recognize phrases such as
"April 2026"— if the parser fails, the filter silently falls back to the default (no restriction,-∞ → today) instead of reporting an error. The tool therefore automatically converts"<Month> <Year>"(in English or Italian) into an explicit ISO range before sending it to Superset. - Correct public URL: it builds the final URL by reading an explicit configuration value (
SUPERSET_WEBSERVER_BASE_URLorWEBDRIVER_BASEURL_USER_FRIENDLY) instead of relying on the default resolver, which in a context such as an MCP call may return the container’s internal hostname rather than the public hostname. - Standalone mode: the generated URL always includes
?standalone=1, which hides Superset’s top navigation bar (logo, Dashboards/Charts/SQL Lab menus) while preserving the authentication and permissions of the user opening the link. - Additional filters: it supports a raw
data_maskparameter to compose other native filters (value filters, numerical ranges, etc.) together with the time filter. - AI agent behavior: by using the
native_filter_configurationtool and relying on the function description, the agent can answer the request “give me the dashboard filtered to June 2026” by providing the public dashboard link with the time filter applied.
Tool Code
# 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),
)