Skip to content

Commit d501ad2

Browse files
feat(mcp): add list and get tools for saved queries and query history
Implements list_saved_queries, get_saved_query_info, list_queries, and get_query_info MCP tools in new saved_query/ and query/ domains. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent a273fe4 commit d501ad2

17 files changed

Lines changed: 1805 additions & 0 deletions

File tree

superset/mcp_service/app.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -620,6 +620,14 @@ def create_mcp_app(
620620
from superset.mcp_service.explore.tool import ( # noqa: F401, E402
621621
generate_explore_link,
622622
)
623+
from superset.mcp_service.query.tool import ( # noqa: F401, E402
624+
get_query_info,
625+
list_queries,
626+
)
627+
from superset.mcp_service.saved_query.tool import ( # noqa: F401, E402
628+
get_saved_query_info,
629+
list_saved_queries,
630+
)
623631
from superset.mcp_service.sql_lab.tool import ( # noqa: F401, E402
624632
execute_sql,
625633
open_sql_lab_with_context,
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
# Licensed to the Apache Software Foundation (ASF) under one
2+
# or more contributor license agreements. See the NOTICE file
3+
# distributed with this work for additional information
4+
# regarding copyright ownership. The ASF licenses this file
5+
# to you under the Apache License, Version 2.0 (the
6+
# "License"); you may not use this file except in compliance
7+
# with the License. You may obtain a copy of the License at
8+
#
9+
# http://www.apache.org/licenses/LICENSE-2.0
10+
#
11+
# Unless required by applicable law or agreed to in writing,
12+
# software distributed under the License is distributed on an
13+
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
14+
# KIND, either express or implied. See the License for the
15+
# specific language governing permissions and limitations
16+
# under the License.
Lines changed: 290 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,290 @@
1+
# Licensed to the Apache Software Foundation (ASF) under one
2+
# or more contributor license agreements. See the NOTICE file
3+
# distributed with this work for additional information
4+
# regarding copyright ownership. The ASF licenses this file
5+
# to you under the Apache License, Version 2.0 (the
6+
# "License"); you may not use this file except in compliance
7+
# with the License. You may obtain a copy of the License at
8+
#
9+
# http://www.apache.org/licenses/LICENSE-2.0
10+
#
11+
# Unless required by applicable law or agreed to in writing,
12+
# software distributed under the License is distributed on an
13+
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
14+
# KIND, either express or implied. See the License for the
15+
# specific language governing permissions and limitations
16+
# under the License.
17+
18+
"""
19+
Pydantic schemas for query history-related responses
20+
"""
21+
22+
from __future__ import annotations
23+
24+
from datetime import datetime
25+
from typing import Annotated, Any, Dict, List, Literal
26+
27+
from pydantic import (
28+
BaseModel,
29+
ConfigDict,
30+
Field,
31+
field_validator,
32+
model_serializer,
33+
model_validator,
34+
PositiveInt,
35+
)
36+
37+
from superset.daos.base import ColumnOperator, ColumnOperatorEnum
38+
from superset.mcp_service.constants import MAX_PAGE_SIZE
39+
from superset.mcp_service.privacy import filter_user_directory_fields
40+
from superset.mcp_service.system.schemas import PaginationInfo
41+
from superset.mcp_service.utils.schema_utils import (
42+
parse_json_or_list,
43+
parse_json_or_model_list,
44+
)
45+
46+
DEFAULT_QUERY_COLUMNS = ["id", "sql", "status", "start_time", "database_id", "schema"]
47+
SORTABLE_QUERY_COLUMNS = [
48+
"id",
49+
"start_time",
50+
"end_time",
51+
"status",
52+
"database_id",
53+
]
54+
ALL_QUERY_COLUMNS = [
55+
"id",
56+
"sql",
57+
"status",
58+
"start_time",
59+
"end_time",
60+
"rows",
61+
"database_id",
62+
"schema",
63+
"tab_name",
64+
"error_message",
65+
"client_id",
66+
"limit",
67+
"progress",
68+
"changed_on",
69+
]
70+
71+
DEFAULT_QUERY_PAGE_SIZE = 25
72+
73+
74+
class QueryFilter(ColumnOperator):
75+
"""
76+
Filter object for query history listing.
77+
col: The column to filter on. Must be one of the allowed filter fields.
78+
opr: The operator to use. Must be one of the supported operators.
79+
value: The value to filter by (type depends on col and opr).
80+
"""
81+
82+
col: Literal["status", "database_id", "schema"] = Field(
83+
...,
84+
description="Column to filter on.",
85+
)
86+
opr: ColumnOperatorEnum = Field(
87+
...,
88+
description="Operator to use.",
89+
)
90+
value: str | int | float | bool | List[str | int | float | bool] = Field(
91+
..., description="Value to filter by (type depends on col and opr)"
92+
)
93+
94+
95+
class QueryInfo(BaseModel):
96+
id: int | None = Field(None, description="Query ID")
97+
sql: str | None = Field(None, description="SQL query text")
98+
status: str | None = Field(None, description="Query execution status")
99+
start_time: float | None = Field(
100+
None, description="Query start time (seconds since epoch)"
101+
)
102+
end_time: float | None = Field(
103+
None, description="Query end time (seconds since epoch)"
104+
)
105+
rows: int | None = Field(None, description="Number of rows returned or affected")
106+
database_id: int | None = Field(None, description="Database connection ID")
107+
schema: str | None = Field(None, description="Database schema name")
108+
tab_name: str | None = Field(None, description="SQL Lab tab name")
109+
error_message: str | None = Field(None, description="Error message if query failed")
110+
client_id: str | None = Field(None, description="Client-assigned query identifier")
111+
limit: int | None = Field(None, description="Row limit applied to the query")
112+
progress: int | None = Field(None, description="Query execution progress (0-100)")
113+
changed_on: str | datetime | None = Field(
114+
None, description="Last modification timestamp"
115+
)
116+
model_config = ConfigDict(
117+
from_attributes=True,
118+
ser_json_timedelta="iso8601",
119+
populate_by_name=True,
120+
)
121+
122+
@model_serializer(mode="wrap")
123+
def _filter_fields_by_context(self, serializer: Any, info: Any) -> Dict[str, Any]:
124+
data = filter_user_directory_fields(serializer(self))
125+
126+
if info.context and isinstance(info.context, dict):
127+
select_columns = info.context.get("select_columns")
128+
if select_columns:
129+
requested_fields = set(select_columns)
130+
return {k: v for k, v in data.items() if k in requested_fields}
131+
132+
return data
133+
134+
135+
class QueryList(BaseModel):
136+
queries: List[QueryInfo]
137+
count: int
138+
total_count: int
139+
page: int
140+
page_size: int
141+
total_pages: int
142+
has_previous: bool
143+
has_next: bool
144+
columns_requested: List[str] = Field(
145+
default_factory=list,
146+
description="Requested columns for the response",
147+
)
148+
columns_loaded: List[str] = Field(
149+
default_factory=list,
150+
description="Columns that were actually loaded for each query",
151+
)
152+
columns_available: List[str] = Field(
153+
default_factory=list,
154+
description="All columns available for selection via select_columns parameter",
155+
)
156+
sortable_columns: List[str] = Field(
157+
default_factory=list,
158+
description="Columns that can be used with order_column parameter",
159+
)
160+
filters_applied: List[QueryFilter] = Field(
161+
default_factory=list,
162+
description="List of advanced filter dicts applied to the query.",
163+
)
164+
pagination: PaginationInfo | None = None
165+
timestamp: datetime | None = None
166+
model_config = ConfigDict(ser_json_timedelta="iso8601")
167+
168+
169+
class ListQueriesRequest(BaseModel):
170+
"""Request schema for list_queries."""
171+
172+
filters: Annotated[
173+
List[QueryFilter],
174+
Field(
175+
default_factory=list,
176+
description="List of filter objects (column, operator, value). Each "
177+
"filter is an object with 'col', 'opr', and 'value' "
178+
"properties. Cannot be used together with 'search'.",
179+
),
180+
]
181+
select_columns: Annotated[
182+
List[str],
183+
Field(
184+
default_factory=list,
185+
description="List of columns to select. Defaults to common columns if not "
186+
"specified.",
187+
),
188+
]
189+
search: Annotated[
190+
str | None,
191+
Field(
192+
default=None,
193+
description="Text search string to match against query fields. "
194+
"Cannot be used together with 'filters'.",
195+
),
196+
]
197+
order_column: Annotated[
198+
str | None,
199+
Field(default=None, description="Column to order results by"),
200+
]
201+
order_direction: Annotated[
202+
Literal["asc", "desc"],
203+
Field(
204+
default="desc",
205+
description="Direction to order results ('asc' or 'desc')",
206+
),
207+
]
208+
page: Annotated[
209+
PositiveInt,
210+
Field(default=1, description="Page number for pagination (1-based)"),
211+
]
212+
page_size: Annotated[
213+
int,
214+
Field(
215+
default=DEFAULT_QUERY_PAGE_SIZE,
216+
gt=0,
217+
le=MAX_PAGE_SIZE,
218+
description=f"Number of items per page (max {MAX_PAGE_SIZE})",
219+
),
220+
]
221+
222+
@field_validator("filters", mode="before")
223+
@classmethod
224+
def parse_filters(cls, v: Any) -> List[QueryFilter]:
225+
"""Accept both JSON string and list of objects."""
226+
return parse_json_or_model_list(v, QueryFilter, "filters")
227+
228+
@field_validator("select_columns", mode="before")
229+
@classmethod
230+
def parse_columns(cls, v: Any) -> List[str]:
231+
"""Accept JSON array, list, or comma-separated string."""
232+
return parse_json_or_list(v, "select_columns")
233+
234+
@model_validator(mode="after")
235+
def validate_search_and_filters(self) -> "ListQueriesRequest":
236+
"""Prevent using both search and filters simultaneously."""
237+
if self.search and self.filters:
238+
raise ValueError(
239+
"Cannot use both 'search' and 'filters' parameters simultaneously. "
240+
"Use either 'search' for text-based searching across multiple fields, "
241+
"or 'filters' for precise column-based filtering, but not both."
242+
)
243+
return self
244+
245+
246+
class QueryError(BaseModel):
247+
error: str = Field(..., description="Error message")
248+
error_type: str = Field(..., description="Type of error")
249+
timestamp: str | datetime | None = Field(None, description="Error timestamp")
250+
model_config = ConfigDict(ser_json_timedelta="iso8601")
251+
252+
@classmethod
253+
def create(cls, error: str, error_type: str) -> "QueryError":
254+
"""Create a standardized QueryError with timestamp."""
255+
from datetime import datetime, timezone
256+
257+
return cls(
258+
error=error, error_type=error_type, timestamp=datetime.now(timezone.utc)
259+
)
260+
261+
262+
class GetQueryInfoRequest(BaseModel):
263+
"""Request schema for get_query_info with support for numeric ID only."""
264+
265+
identifier: Annotated[
266+
int,
267+
Field(description="Query ID (numeric)"),
268+
]
269+
270+
271+
def serialize_query_object(query: Any) -> QueryInfo | None:
272+
if not query:
273+
return None
274+
275+
return QueryInfo(
276+
id=getattr(query, "id", None),
277+
sql=getattr(query, "sql", None),
278+
status=getattr(query, "status", None),
279+
start_time=getattr(query, "start_time", None),
280+
end_time=getattr(query, "end_time", None),
281+
rows=getattr(query, "rows", None),
282+
database_id=getattr(query, "database_id", None),
283+
schema=getattr(query, "schema", None),
284+
tab_name=getattr(query, "tab_name", None),
285+
error_message=getattr(query, "error_message", None),
286+
client_id=getattr(query, "client_id", None),
287+
limit=getattr(query, "limit", None),
288+
progress=getattr(query, "progress", None),
289+
changed_on=getattr(query, "changed_on", None),
290+
)
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
# Licensed to the Apache Software Foundation (ASF) under one
2+
# or more contributor license agreements. See the NOTICE file
3+
# distributed with this work for additional information
4+
# regarding copyright ownership. The ASF licenses this file
5+
# to you under the Apache License, Version 2.0 (the
6+
# "License"); you may not use this file except in compliance
7+
# with the License. You may obtain a copy of the License at
8+
#
9+
# http://www.apache.org/licenses/LICENSE-2.0
10+
#
11+
# Unless required by applicable law or agreed to in writing,
12+
# software distributed under the License is distributed on an
13+
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
14+
# KIND, either express or implied. See the License for the
15+
# specific language governing permissions and limitations
16+
# under the License.
17+
18+
from .get_query_info import get_query_info
19+
from .list_queries import list_queries
20+
21+
__all__ = [
22+
"list_queries",
23+
"get_query_info",
24+
]

0 commit comments

Comments
 (0)