Skip to content
Draft
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
75 changes: 75 additions & 0 deletions superset/commands/query/create.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
# 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.
import logging
from functools import partial
from typing import Any

from flask import g
from flask_appbuilder.models.sqla import Model
from marshmallow import ValidationError

from superset.commands.base import BaseCommand, CreateMixin
from superset.commands.query.exceptions import (
SavedQueryCreateFailedError,
SavedQueryInvalidError,
)
from superset.daos.query import SavedQueryDAO
from superset.utils.decorators import on_error, transaction

logger = logging.getLogger(__name__)


class CreateSavedQueryCommand(CreateMixin, BaseCommand):
def __init__(self, data: dict[str, Any]):
self._properties = data.copy()

@transaction(on_error=partial(on_error, reraise=SavedQueryCreateFailedError))
def run(self) -> Model:
self.validate()
self._properties["user_id"] = g.user.id
saved_query = SavedQueryDAO.create(attributes=self._properties)
return saved_query

def validate(self) -> None:
from superset.extensions import db, security_manager
from superset.models.core import Database

exceptions: list[ValidationError] = []

db_id = self._properties.get("db_id")
if not db_id:
exceptions.append(ValidationError("db_id is required", field_name="db_id"))
raise SavedQueryInvalidError(exceptions=exceptions)

database = db.session.query(Database).filter_by(id=db_id).first()

if not database:
exceptions.append(
ValidationError(
f"Database with ID {db_id} not found", field_name="db_id"
)
)
raise SavedQueryInvalidError(exceptions=exceptions)

if not security_manager.can_access_database(database):
exceptions.append(
ValidationError(
f"Access denied to database {database.database_name}",
field_name="db_id",
)
)
raise SavedQueryInvalidError(exceptions=exceptions)
5 changes: 5 additions & 0 deletions superset/commands/query/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
from superset.commands.exceptions import (
CommandException,
CommandInvalidError,
CreateFailedError,
DeleteFailedError,
ImportFailedError,
)
Expand All @@ -38,3 +39,7 @@ class SavedQueryImportError(ImportFailedError):

class SavedQueryInvalidError(CommandInvalidError):
message = _("Saved query parameters are invalid.")


class SavedQueryCreateFailedError(CreateFailedError):
message = _("Saved query could not be created.")
11 changes: 8 additions & 3 deletions superset/mcp_service/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,7 @@ def get_default_instructions(
SQL Lab Integration:
- execute_sql: Execute SQL queries and get results (requires database_id and SQL access)
- save_sql_query: Save a SQL query to Saved Queries list (requires write access)
- create_saved_query: Create a saved query with label, SQL, and optional schema/description (requires write access)
- open_sql_lab_with_context: Generate SQL Lab URL with pre-filled sql

Schema Discovery:
Expand Down Expand Up @@ -378,9 +379,10 @@ def get_default_instructions(
{_instance_info_role_bullet}- ALWAYS check the user's roles BEFORE suggesting write operations (creating datasets,
charts, or dashboards). SQL execution is a separate permission — see execute_sql below.
- Write tools (generate_chart, generate_dashboard, update_chart, create_virtual_dataset,
save_sql_query, add_chart_to_existing_dashboard, update_chart_preview) require write
permissions. These tools are only listed for users who have the necessary access.
If a write tool does not appear in the tool list, the current user lacks write access.
save_sql_query, create_saved_query, add_chart_to_existing_dashboard, update_chart_preview)
require write permissions. These tools are only listed for users who have the necessary
access. If a write tool does not appear in the tool list, the current user lacks write
access.
- execute_sql requires SQL Lab access (execute_sql_query permission), which is separate
from write access. A user may have SQL Lab access without having write access to charts
or dashboards, and vice versa.
Expand Down Expand Up @@ -654,6 +656,9 @@ def create_mcp_app(
from superset.mcp_service.explore.tool import ( # noqa: F401, E402
generate_explore_link,
)
from superset.mcp_service.saved_query.tool import ( # noqa: F401, E402
create_saved_query,
)
from superset.mcp_service.sql_lab.tool import ( # noqa: F401, E402
execute_sql,
open_sql_lab_with_context,
Expand Down
16 changes: 16 additions & 0 deletions superset/mcp_service/saved_query/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
# 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.
93 changes: 93 additions & 0 deletions superset/mcp_service/saved_query/schemas.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
# 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.

"""Schemas for SavedQuery MCP tools."""

from pydantic import BaseModel, ConfigDict, Field, field_validator


class CreateSavedQueryRequest(BaseModel):
"""Request schema for create_saved_query."""

model_config = ConfigDict(populate_by_name=True)

label: str = Field(
...,
min_length=1,
max_length=256,
description="Name for the saved query (shown in the Saved Queries list).",
)
sql: str = Field(
...,
description="SQL query text to save.",
)
db_id: int = Field(
...,
description=(
"ID of the database connection. Use list_databases to find valid IDs."
),
)
schema: str | None = Field(
None,
description="Database schema the query targets (optional).",
)
description: str | None = Field(
None,
description="Human-readable description of the saved query (optional).",
)
template_parameters: str | None = Field(
None,
description=(
"JSON string of Jinja2 template parameters for the query (optional)."
),
)

@field_validator("sql")
@classmethod
def sql_not_empty(cls, v: str) -> str:
if not v or not v.strip():
raise ValueError("sql must not be empty")
return v.strip()

@field_validator("label")
@classmethod
def label_not_empty(cls, v: str) -> str:
if not v or not v.strip():
raise ValueError("label must not be empty")
return v.strip()


class CreateSavedQueryResponse(BaseModel):
"""Response schema for create_saved_query."""

id: int | None = Field(
None,
description="Saved query ID. None if creation failed.",
)
label: str = Field(..., description="Name of the saved query.")
sql: str = Field(..., description="SQL query text stored.")
db_id: int = Field(..., description="Database ID used.")
schema: str | None = Field(None, description="Database schema (if set).")
description: str | None = Field(None, description="Query description (if set).")
url: str | None = Field(
None,
description=(
"URL to open this saved query in SQL Lab "
"(e.g., /sqllab?savedQueryId=42). None if creation failed."
),
)
error: str | None = Field(None, description="Error message if creation failed.")
22 changes: 22 additions & 0 deletions superset/mcp_service/saved_query/tool/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
# 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.

from superset.mcp_service.saved_query.tool.create_saved_query import (
create_saved_query,
)

__all__ = ["create_saved_query"]
126 changes: 126 additions & 0 deletions superset/mcp_service/saved_query/tool/create_saved_query.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
# 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.

import logging
from typing import Any

from fastmcp import Context
from superset_core.mcp.decorators import tool, ToolAnnotations

from superset.extensions import event_logger
from superset.mcp_service.saved_query.schemas import (
CreateSavedQueryRequest,
CreateSavedQueryResponse,
)

logger = logging.getLogger(__name__)


@tool(
tags=["mutate"],
class_permission_name="SavedQuery",
method_permission_name="write",
annotations=ToolAnnotations(
title="Create saved query",
readOnlyHint=False,
destructiveHint=False,
),
)
async def create_saved_query(
request: CreateSavedQueryRequest, ctx: Context
) -> CreateSavedQueryResponse:
"""Save a SQL query to the Saved Queries list so it can be reloaded and shared.

Creates a persistent SavedQuery that appears in the Saved Queries page
and can be opened in SQL Lab via the returned URL.

Workflow:
1. Call execute_sql to verify the query returns expected results
2. Call this tool with a label and the SQL to persist it
3. Use the returned ``url`` to open the saved query in SQL Lab
"""
await ctx.info(
"Creating saved query: db_id=%s, label=%r" % (request.db_id, request.label)
)

try:
from superset.commands.query.create import CreateSavedQueryCommand
from superset.commands.query.exceptions import (
SavedQueryCreateFailedError,
SavedQueryInvalidError,
)
from superset.mcp_service.utils.url_utils import get_superset_base_url

properties: dict[str, Any] = {
"db_id": request.db_id,
"label": request.label,
"sql": request.sql,
}
if request.schema is not None:
properties["schema"] = request.schema
if request.description is not None:
properties["description"] = request.description
if request.template_parameters is not None:
properties["template_parameters"] = request.template_parameters

with event_logger.log_context(action="mcp.create_saved_query.create"):
saved_query = CreateSavedQueryCommand(properties).run()

base_url = get_superset_base_url()
saved_query_url = f"{base_url}/sqllab?savedQueryId={saved_query.id}"

await ctx.info(
"Saved query created: id=%s, url=%s" % (saved_query.id, saved_query_url)
)

return CreateSavedQueryResponse(
id=saved_query.id,
label=saved_query.label,
sql=saved_query.sql,
db_id=saved_query.db_id,
schema=saved_query.schema or None,
description=saved_query.description or None,
url=saved_query_url,
)

except SavedQueryInvalidError as exc:
messages = exc.normalized_messages()
await ctx.warning("Saved query validation failed: %s" % (messages,))
return CreateSavedQueryResponse(
id=None,
label=request.label,
sql=request.sql,
db_id=request.db_id,
url=None,
error=str(messages),
)
except SavedQueryCreateFailedError as exc:
await ctx.error("Saved query creation failed: %s" % (str(exc),))
return CreateSavedQueryResponse(
id=None,
label=request.label,
sql=request.sql,
db_id=request.db_id,
url=None,
error=f"Failed to create saved query: {exc}",
)
except Exception as exc:
await ctx.error(
"Unexpected error creating saved query: %s: %s"
% (type(exc).__name__, str(exc))
)
raise
Loading